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 blank_idx: usize,
295 flavor: MarkdownFlavor,
296 ) -> bool {
297 let mut prev_quote_idx = None;
309 let mut next_quote_idx = None;
310
311 for i in (0..blank_idx).rev() {
313 if Self::is_in_skip_context(line_infos, i) {
314 continue;
315 }
316 let line = lines[i];
317 if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
319 prev_quote_idx = Some(i);
320 break;
321 }
322 }
323
324 for (i, line) in lines.iter().enumerate().skip(blank_idx + 1) {
326 if Self::is_in_skip_context(line_infos, i) {
327 continue;
328 }
329 if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
331 next_quote_idx = Some(i);
332 break;
333 }
334 }
335
336 let (Some(prev_idx), Some(next_idx)) = (prev_quote_idx, next_quote_idx) else {
337 return false;
338 };
339
340 let prev_is_callout = Self::is_callout_block(lines, line_infos, prev_idx, flavor);
346 let next_is_callout = Self::is_callout_block(lines, line_infos, next_idx, flavor);
347 if prev_is_callout || next_is_callout {
348 return false;
349 }
350
351 if Self::has_content_between(lines, line_infos, prev_idx + 1, next_idx) {
353 return false;
354 }
355
356 let (prev_level, prev_whitespace_end) = Self::get_blockquote_info(lines[prev_idx]);
358 let (next_level, next_whitespace_end) = Self::get_blockquote_info(lines[next_idx]);
359
360 if next_level < prev_level {
363 return false;
364 }
365
366 let prev_line = lines[prev_idx];
368 let next_line = lines[next_idx];
369 let prev_indent = &prev_line[..prev_whitespace_end];
370 let next_indent = &next_line[..next_whitespace_end];
371
372 prev_indent == next_indent
375 }
376
377 fn is_problematic_blank_line(
379 lines: &[&str],
380 line_infos: &[LineInfo],
381 index: usize,
382 flavor: MarkdownFlavor,
383 ) -> Option<(usize, String)> {
384 let current_line = lines[index];
385
386 if !current_line.trim().is_empty() || Self::is_blockquote_line(current_line) {
388 return None;
389 }
390
391 if !Self::are_likely_same_blockquote(lines, line_infos, index, flavor) {
394 return None;
395 }
396
397 for i in (0..index).rev() {
400 if Self::is_in_skip_context(line_infos, i) {
401 continue;
402 }
403 let line = lines[i];
404 if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
406 let (level, whitespace_end) = Self::get_blockquote_info(line);
407 let indent = &line[..whitespace_end];
408 let mut fix = String::with_capacity(indent.len() + level);
409 fix.push_str(indent);
410 for _ in 0..level {
411 fix.push('>');
412 }
413 return Some((level, fix));
414 }
415 }
416
417 None
418 }
419}
420
421impl Rule for MD028NoBlanksBlockquote {
422 fn name(&self) -> &'static str {
423 "MD028"
424 }
425
426 fn description(&self) -> &'static str {
427 "Blank line inside blockquote"
428 }
429
430 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
431 if !ctx.content.contains('>') {
433 return Ok(Vec::new());
434 }
435
436 let mut warnings = Vec::new();
437
438 let lines = ctx.raw_lines();
440
441 let mut blank_line_indices = Vec::new();
443 let mut has_blockquotes = false;
444
445 for (line_idx, line) in lines.iter().enumerate() {
446 if line_idx < ctx.lines.len() {
448 let li = &ctx.lines[line_idx];
449 if li.in_code_block || li.in_html_comment || li.in_mdx_comment || li.in_html_block || li.in_front_matter
450 {
451 continue;
452 }
453 }
454
455 if line.trim().is_empty() {
456 blank_line_indices.push(line_idx);
457 } else if Self::is_blockquote_line(line) {
458 has_blockquotes = true;
459 }
460 }
461
462 if !has_blockquotes {
464 return Ok(Vec::new());
465 }
466
467 for &line_idx in &blank_line_indices {
469 let line_num = line_idx + 1;
470
471 if let Some((level, fix_content)) = Self::is_problematic_blank_line(lines, &ctx.lines, line_idx, ctx.flavor)
473 {
474 let line = lines[line_idx];
475 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
476
477 warnings.push(LintWarning {
478 rule_name: Some(self.name().to_string()),
479 message: format!("Blank line inside blockquote (level {level})"),
480 line: start_line,
481 column: start_col,
482 end_line,
483 end_column: end_col,
484 severity: Severity::Warning,
485 fix: if self.config.fix {
488 Some(Fix::new(
489 ctx.line_index
490 .line_col_to_byte_range_with_length(line_num, 1, line.len()),
491 fix_content,
492 ))
493 } else {
494 None
495 },
496 });
497 }
498 }
499
500 Ok(warnings)
501 }
502
503 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
504 if !self.config.fix || self.should_skip(ctx) {
507 return Ok(ctx.content.to_string());
508 }
509 let warnings = self.check(ctx)?;
510 if warnings.is_empty() {
511 return Ok(ctx.content.to_string());
512 }
513 let warnings =
514 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
515 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
516 .map_err(crate::rule::LintError::InvalidInput)
517 }
518
519 fn category(&self) -> RuleCategory {
521 RuleCategory::Blockquote
522 }
523
524 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
526 !ctx.likely_has_blockquotes()
527 }
528
529 fn as_any(&self) -> &dyn std::any::Any {
530 self
531 }
532
533 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
534 where
535 Self: Sized,
536 {
537 let rule_config: MD028Config = load_rule_config(config);
538 Box::new(MD028NoBlanksBlockquote::with_config(rule_config))
539 }
540
541 fn default_config_section(&self) -> Option<(String, toml::Value)> {
542 let default_config = MD028Config::default();
543 let json_value = serde_json::to_value(&default_config).ok()?;
544 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
545 if let toml::Value::Table(table) = toml_value
546 && !table.is_empty()
547 {
548 return Some((MD028Config::RULE_NAME.to_string(), toml::Value::Table(table)));
549 }
550 None
551 }
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use crate::lint_context::LintContext;
558
559 #[test]
560 fn test_default_warns_but_does_not_merge_blockquotes() {
561 let rule = MD028NoBlanksBlockquote::from_config(&crate::config::Config::default());
567 let content = "> Quote by Alice.\n\n> Quote by Bob.\n";
568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569
570 let warnings = rule.check(&ctx).unwrap();
571 assert_eq!(warnings.len(), 1, "detection should still fire by default");
572 assert!(warnings[0].fix.is_none(), "default warnings must not carry a fix");
573
574 let fixed = rule.fix(&ctx).unwrap();
575 assert_eq!(fixed, content, "default fmt must not merge distinct blockquotes");
576 }
577
578 #[test]
579 fn test_fix_enabled_merges_blockquotes() {
580 let rule = MD028NoBlanksBlockquote::with_fix(true);
583 let content = "> A quote\n\n> its continuation\n";
584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585 let fixed = rule.fix(&ctx).unwrap();
586 assert_eq!(fixed, "> A quote\n>\n> its continuation\n");
587 }
588
589 #[test]
590 fn test_no_blockquotes() {
591 let rule = MD028NoBlanksBlockquote::with_fix(true);
592 let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594 let result = rule.check(&ctx).unwrap();
595 assert!(result.is_empty(), "Should not flag content without blockquotes");
596 }
597
598 #[test]
599 fn test_valid_blockquote_no_blanks() {
600 let rule = MD028NoBlanksBlockquote::with_fix(true);
601 let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603 let result = rule.check(&ctx).unwrap();
604 assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
605 }
606
607 #[test]
608 fn test_blockquote_with_empty_line_marker() {
609 let rule = MD028NoBlanksBlockquote::with_fix(true);
610 let content = "> First line\n>\n> Third line";
612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613 let result = rule.check(&ctx).unwrap();
614 assert!(result.is_empty(), "Should not flag lines with just > marker");
615 }
616
617 #[test]
618 fn test_blockquote_with_empty_line_marker_and_space() {
619 let rule = MD028NoBlanksBlockquote::with_fix(true);
620 let content = "> First line\n> \n> Third line";
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623 let result = rule.check(&ctx).unwrap();
624 assert!(result.is_empty(), "Should not flag lines with > and space");
625 }
626
627 #[test]
628 fn test_blank_line_in_blockquote() {
629 let rule = MD028NoBlanksBlockquote::with_fix(true);
630 let content = "> First line\n\n> Third line";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633 let result = rule.check(&ctx).unwrap();
634 assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
635 assert_eq!(result[0].line, 2);
636 assert!(result[0].message.contains("Blank line inside blockquote"));
637 }
638
639 #[test]
640 fn test_multiple_blank_lines() {
641 let rule = MD028NoBlanksBlockquote::with_fix(true);
642 let content = "> First\n\n\n> Fourth";
643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
644 let result = rule.check(&ctx).unwrap();
645 assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
647 assert_eq!(result[0].line, 2);
648 assert_eq!(result[1].line, 3);
649 }
650
651 #[test]
652 fn test_nested_blockquote_blank() {
653 let rule = MD028NoBlanksBlockquote::with_fix(true);
654 let content = ">> Nested quote\n\n>> More nested";
655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
656 let result = rule.check(&ctx).unwrap();
657 assert_eq!(result.len(), 1);
658 assert_eq!(result[0].line, 2);
659 }
660
661 #[test]
662 fn test_nested_blockquote_with_marker() {
663 let rule = MD028NoBlanksBlockquote::with_fix(true);
664 let content = ">> Nested quote\n>>\n>> More nested";
666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
667 let result = rule.check(&ctx).unwrap();
668 assert!(result.is_empty(), "Should not flag lines with >> marker");
669 }
670
671 #[test]
672 fn test_fix_single_blank() {
673 let rule = MD028NoBlanksBlockquote::with_fix(true);
674 let content = "> First\n\n> Third";
675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
676 let fixed = rule.fix(&ctx).unwrap();
677 assert_eq!(fixed, "> First\n>\n> Third");
678 }
679
680 #[test]
681 fn test_fix_nested_blank() {
682 let rule = MD028NoBlanksBlockquote::with_fix(true);
683 let content = ">> Nested\n\n>> More";
684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685 let fixed = rule.fix(&ctx).unwrap();
686 assert_eq!(fixed, ">> Nested\n>>\n>> More");
687 }
688
689 #[test]
690 fn test_fix_with_indentation() {
691 let rule = MD028NoBlanksBlockquote::with_fix(true);
692 let content = " > Indented quote\n\n > More";
693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694 let fixed = rule.fix(&ctx).unwrap();
695 assert_eq!(fixed, " > Indented quote\n >\n > More");
696 }
697
698 #[test]
699 fn test_mixed_levels() {
700 let rule = MD028NoBlanksBlockquote::with_fix(true);
701 let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704 let result = rule.check(&ctx).unwrap();
705 assert_eq!(result.len(), 1);
708 assert_eq!(result[0].line, 2);
709 }
710
711 #[test]
712 fn test_blockquote_with_code_block() {
713 let rule = MD028NoBlanksBlockquote::with_fix(true);
714 let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
716 let result = rule.check(&ctx).unwrap();
717 assert!(result.is_empty(), "Should not flag line with > marker");
719 }
720
721 #[test]
722 fn test_category() {
723 let rule = MD028NoBlanksBlockquote::with_fix(true);
724 assert_eq!(rule.category(), RuleCategory::Blockquote);
725 }
726
727 #[test]
728 fn test_should_skip() {
729 let rule = MD028NoBlanksBlockquote::with_fix(true);
730 let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard, None);
731 assert!(rule.should_skip(&ctx1));
732
733 let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard, None);
734 assert!(!rule.should_skip(&ctx2));
735 }
736
737 #[test]
738 fn test_empty_content() {
739 let rule = MD028NoBlanksBlockquote::with_fix(true);
740 let content = "";
741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
742 let result = rule.check(&ctx).unwrap();
743 assert!(result.is_empty());
744 }
745
746 #[test]
747 fn test_blank_after_blockquote() {
748 let rule = MD028NoBlanksBlockquote::with_fix(true);
749 let content = "> Quote\n\nNot a quote";
750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
751 let result = rule.check(&ctx).unwrap();
752 assert!(result.is_empty(), "Blank line after blockquote ends is valid");
753 }
754
755 #[test]
756 fn test_blank_before_blockquote() {
757 let rule = MD028NoBlanksBlockquote::with_fix(true);
758 let content = "Not a quote\n\n> Quote";
759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let result = rule.check(&ctx).unwrap();
761 assert!(result.is_empty(), "Blank line before blockquote starts is valid");
762 }
763
764 #[test]
765 fn test_preserve_trailing_newline() {
766 let rule = MD028NoBlanksBlockquote::with_fix(true);
767 let content = "> Quote\n\n> More\n";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let fixed = rule.fix(&ctx).unwrap();
770 assert!(fixed.ends_with('\n'));
771
772 let content_no_newline = "> Quote\n\n> More";
773 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
774 let fixed2 = rule.fix(&ctx2).unwrap();
775 assert!(!fixed2.ends_with('\n'));
776 }
777
778 #[test]
779 fn test_document_structure_extension() {
780 let rule = MD028NoBlanksBlockquote::with_fix(true);
781 let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard, None);
782 let result = rule.check(&ctx).unwrap();
784 assert!(result.is_empty(), "Should not flag valid blockquote");
785
786 let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard, None);
788 assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
789 }
790
791 #[test]
792 fn test_deeply_nested_blank() {
793 let rule = MD028NoBlanksBlockquote::with_fix(true);
794 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_eq!(result.len(), 1);
798
799 let fixed = rule.fix(&ctx).unwrap();
800 assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
801 }
802
803 #[test]
804 fn test_deeply_nested_with_marker() {
805 let rule = MD028NoBlanksBlockquote::with_fix(true);
806 let content = ">>> Deep nest\n>>>\n>>> More deep";
808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
809 let result = rule.check(&ctx).unwrap();
810 assert!(result.is_empty(), "Should not flag lines with >>> marker");
811 }
812
813 #[test]
814 fn test_complex_blockquote_structure() {
815 let rule = MD028NoBlanksBlockquote::with_fix(true);
816 let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
819 let result = rule.check(&ctx).unwrap();
820 assert!(result.is_empty(), "Should not flag line with > marker");
821 }
822
823 #[test]
824 fn test_complex_with_blank() {
825 let rule = MD028NoBlanksBlockquote::with_fix(true);
826 let content = "> Level 1\n> > Nested\n\n> Back to level 1";
829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
830 let result = rule.check(&ctx).unwrap();
831 assert_eq!(
832 result.len(),
833 0,
834 "Blank between different nesting levels is not inside blockquote"
835 );
836 }
837
838 #[test]
845 fn test_gfm_alert_detection_note() {
846 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
847 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE] Additional text"));
848 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
849 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!note]")); assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!Note]")); }
852
853 #[test]
854 fn test_gfm_alert_detection_all_types() {
855 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
857 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!TIP]"));
858 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!IMPORTANT]"));
859 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!WARNING]"));
860 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!CAUTION]"));
861 }
862
863 #[test]
864 fn test_gfm_alert_detection_not_alert() {
865 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> Regular blockquote"));
867 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!INVALID]"));
868 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("> ")); }
874
875 #[test]
876 fn test_gfm_alerts_separated_by_blank_line() {
877 let rule = MD028NoBlanksBlockquote::with_fix(true);
879 let content = "> [!TIP]\n> Here's a github tip\n\n> [!NOTE]\n> Here's a github note";
880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881 let result = rule.check(&ctx).unwrap();
882 assert!(result.is_empty(), "Should not flag blank line between GFM alerts");
883 }
884
885 #[test]
886 fn test_gfm_alerts_all_five_types_separated() {
887 let rule = MD028NoBlanksBlockquote::with_fix(true);
889 let content = r#"> [!NOTE]
890> Note content
891
892> [!TIP]
893> Tip content
894
895> [!IMPORTANT]
896> Important content
897
898> [!WARNING]
899> Warning content
900
901> [!CAUTION]
902> Caution content"#;
903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
904 let result = rule.check(&ctx).unwrap();
905 assert!(
906 result.is_empty(),
907 "Should not flag blank lines between any GFM alert types"
908 );
909 }
910
911 #[test]
912 fn test_gfm_alert_with_multiple_lines() {
913 let rule = MD028NoBlanksBlockquote::with_fix(true);
915 let content = r#"> [!WARNING]
916> This is a warning
917> with multiple lines
918> of content
919
920> [!NOTE]
921> This is a note"#;
922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923 let result = rule.check(&ctx).unwrap();
924 assert!(
925 result.is_empty(),
926 "Should not flag blank line between multi-line GFM alerts"
927 );
928 }
929
930 #[test]
931 fn test_gfm_alert_followed_by_regular_blockquote() {
932 let rule = MD028NoBlanksBlockquote::with_fix(true);
934 let content = "> [!TIP]\n> A helpful tip\n\n> Regular blockquote";
935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936 let result = rule.check(&ctx).unwrap();
937 assert!(result.is_empty(), "Should not flag blank line after GFM alert");
938 }
939
940 #[test]
941 fn test_regular_blockquote_followed_by_gfm_alert() {
942 let rule = MD028NoBlanksBlockquote::with_fix(true);
944 let content = "> Regular blockquote\n\n> [!NOTE]\n> Important note";
945 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946 let result = rule.check(&ctx).unwrap();
947 assert!(result.is_empty(), "Should not flag blank line before GFM alert");
948 }
949
950 #[test]
951 fn test_regular_blockquotes_still_flagged() {
952 let rule = MD028NoBlanksBlockquote::with_fix(true);
954 let content = "> First blockquote\n\n> Second blockquote";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 let result = rule.check(&ctx).unwrap();
957 assert_eq!(
958 result.len(),
959 1,
960 "Should still flag blank line between regular blockquotes"
961 );
962 }
963
964 #[test]
965 fn test_gfm_alert_blank_line_within_same_alert() {
966 let rule = MD028NoBlanksBlockquote::with_fix(true);
969 let content = "> [!NOTE]\n> First paragraph\n\n> Second paragraph of same note";
970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
971 let result = rule.check(&ctx).unwrap();
972 assert!(
977 result.is_empty(),
978 "GFM alert status propagates to subsequent blockquote lines"
979 );
980 }
981
982 #[test]
983 fn test_gfm_alert_case_insensitive() {
984 let rule = MD028NoBlanksBlockquote::with_fix(true);
985 let content = "> [!note]\n> lowercase\n\n> [!TIP]\n> uppercase\n\n> [!Warning]\n> mixed";
986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
987 let result = rule.check(&ctx).unwrap();
988 assert!(result.is_empty(), "GFM alert detection should be case insensitive");
989 }
990
991 #[test]
992 fn test_gfm_alert_with_nested_blockquote() {
993 let rule = MD028NoBlanksBlockquote::with_fix(true);
995 let content = "> [!NOTE]\n> > Nested quote inside alert\n\n> [!TIP]\n> Tip";
996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
997 let result = rule.check(&ctx).unwrap();
998 assert!(
999 result.is_empty(),
1000 "Should not flag blank between alerts even with nested content"
1001 );
1002 }
1003
1004 #[test]
1005 fn test_gfm_alert_indented() {
1006 let rule = MD028NoBlanksBlockquote::with_fix(true);
1007 let content = " > [!NOTE]\n > Indented note\n\n > [!TIP]\n > Indented tip";
1009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1010 let result = rule.check(&ctx).unwrap();
1011 assert!(result.is_empty(), "Should not flag blank between indented GFM alerts");
1012 }
1013
1014 #[test]
1015 fn test_gfm_alert_mixed_with_regular_content() {
1016 let rule = MD028NoBlanksBlockquote::with_fix(true);
1018 let content = r#"# Heading
1019
1020Some paragraph.
1021
1022> [!NOTE]
1023> Important note
1024
1025More paragraph text.
1026
1027> [!WARNING]
1028> Be careful!
1029
1030Final text."#;
1031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1032 let result = rule.check(&ctx).unwrap();
1033 assert!(
1034 result.is_empty(),
1035 "GFM alerts in mixed document should not trigger warnings"
1036 );
1037 }
1038
1039 #[test]
1040 fn test_gfm_alert_fix_not_applied() {
1041 let rule = MD028NoBlanksBlockquote::with_fix(true);
1043 let content = "> [!TIP]\n> Tip\n\n> [!NOTE]\n> Note";
1044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045 let fixed = rule.fix(&ctx).unwrap();
1046 assert_eq!(fixed, content, "Fix should not modify blank lines between GFM alerts");
1047 }
1048
1049 #[test]
1050 fn test_gfm_alert_multiple_blank_lines_between() {
1051 let rule = MD028NoBlanksBlockquote::with_fix(true);
1053 let content = "> [!NOTE]\n> Note\n\n\n> [!TIP]\n> Tip";
1054 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1055 let result = rule.check(&ctx).unwrap();
1056 assert!(
1057 result.is_empty(),
1058 "Should not flag multiple blank lines between GFM alerts"
1059 );
1060 }
1061
1062 #[test]
1069 fn test_obsidian_callout_detection() {
1070 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]"));
1072 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!info]"));
1073 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!todo]"));
1074 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!success]"));
1075 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!question]"));
1076 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!failure]"));
1077 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!danger]"));
1078 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!bug]"));
1079 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!example]"));
1080 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!quote]"));
1081 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!cite]"));
1082 }
1083
1084 #[test]
1085 fn test_obsidian_callout_custom_types() {
1086 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!custom]"));
1088 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my-callout]"));
1089 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my_callout]"));
1090 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!MyCallout]"));
1091 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!callout123]"));
1092 }
1093
1094 #[test]
1095 fn test_obsidian_callout_foldable() {
1096 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]+ Expanded"));
1098 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1099 "> [!NOTE]- Collapsed"
1100 ));
1101 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!WARNING]+"));
1102 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!TIP]-"));
1103 }
1104
1105 #[test]
1106 fn test_obsidian_callout_with_title() {
1107 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1109 "> [!NOTE] Custom Title"
1110 ));
1111 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1112 "> [!WARNING]+ Be Careful!"
1113 ));
1114 }
1115
1116 #[test]
1117 fn test_obsidian_callout_invalid() {
1118 assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1120 "> Regular blockquote"
1121 ));
1122 assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [NOTE]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1125 "Regular text [!NOTE]"
1126 )); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("")); }
1129
1130 #[test]
1131 fn test_obsidian_callouts_separated_by_blank_line() {
1132 let rule = MD028NoBlanksBlockquote::with_fix(true);
1134 let content = "> [!info]\n> Some info\n\n> [!todo]\n> A todo item";
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 Obsidian callouts"
1140 );
1141 }
1142
1143 #[test]
1144 fn test_obsidian_custom_callouts_separated() {
1145 let rule = MD028NoBlanksBlockquote::with_fix(true);
1147 let content = "> [!my-custom]\n> Custom content\n\n> [!another_custom]\n> More 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 custom Obsidian callouts"
1153 );
1154 }
1155
1156 #[test]
1157 fn test_obsidian_foldable_callouts_separated() {
1158 let rule = MD028NoBlanksBlockquote::with_fix(true);
1160 let content = "> [!NOTE]+ Expanded\n> Content\n\n> [!WARNING]- Collapsed\n> Warning content";
1161 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1162 let result = rule.check(&ctx).unwrap();
1163 assert!(
1164 result.is_empty(),
1165 "Should not flag blank line between foldable Obsidian callouts"
1166 );
1167 }
1168
1169 #[test]
1170 fn test_obsidian_custom_not_recognized_in_standard_flavor() {
1171 let rule = MD028NoBlanksBlockquote::with_fix(true);
1174 let content = "> [!info]\n> Info content\n\n> [!todo]\n> Todo content";
1175 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1176 let result = rule.check(&ctx).unwrap();
1177 assert_eq!(
1179 result.len(),
1180 1,
1181 "Custom callout types should be flagged in Standard flavor"
1182 );
1183 }
1184
1185 #[test]
1186 fn test_obsidian_gfm_alerts_work_in_both_flavors() {
1187 let rule = MD028NoBlanksBlockquote::with_fix(true);
1189 let content = "> [!NOTE]\n> Note\n\n> [!WARNING]\n> Warning";
1190
1191 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1193 let result_standard = rule.check(&ctx_standard).unwrap();
1194 assert!(result_standard.is_empty(), "GFM alerts should work in Standard flavor");
1195
1196 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1198 let result_obsidian = rule.check(&ctx_obsidian).unwrap();
1199 assert!(
1200 result_obsidian.is_empty(),
1201 "GFM alerts should also work in Obsidian flavor"
1202 );
1203 }
1204
1205 #[test]
1206 fn test_obsidian_callout_all_builtin_types() {
1207 let rule = MD028NoBlanksBlockquote::with_fix(true);
1209 let content = r#"> [!note]
1210> Note
1211
1212> [!abstract]
1213> Abstract
1214
1215> [!summary]
1216> Summary
1217
1218> [!info]
1219> Info
1220
1221> [!todo]
1222> Todo
1223
1224> [!tip]
1225> Tip
1226
1227> [!success]
1228> Success
1229
1230> [!question]
1231> Question
1232
1233> [!warning]
1234> Warning
1235
1236> [!failure]
1237> Failure
1238
1239> [!danger]
1240> Danger
1241
1242> [!bug]
1243> Bug
1244
1245> [!example]
1246> Example
1247
1248> [!quote]
1249> Quote"#;
1250 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1251 let result = rule.check(&ctx).unwrap();
1252 assert!(result.is_empty(), "All Obsidian callout types should be recognized");
1253 }
1254
1255 #[test]
1256 fn test_obsidian_fix_not_applied_to_callouts() {
1257 let rule = MD028NoBlanksBlockquote::with_fix(true);
1259 let content = "> [!info]\n> Info\n\n> [!todo]\n> Todo";
1260 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1261 let fixed = rule.fix(&ctx).unwrap();
1262 assert_eq!(
1263 fixed, content,
1264 "Fix should not modify blank lines between Obsidian callouts"
1265 );
1266 }
1267
1268 #[test]
1269 fn test_obsidian_regular_blockquotes_still_flagged() {
1270 let rule = MD028NoBlanksBlockquote::with_fix(true);
1272 let content = "> First blockquote\n\n> Second blockquote";
1273 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1274 let result = rule.check(&ctx).unwrap();
1275 assert_eq!(
1276 result.len(),
1277 1,
1278 "Regular blockquotes should still be flagged in Obsidian flavor"
1279 );
1280 }
1281
1282 #[test]
1283 fn test_obsidian_callout_mixed_with_regular_blockquote() {
1284 let rule = MD028NoBlanksBlockquote::with_fix(true);
1286 let content = "> [!note]\n> Note content\n\n> Regular blockquote";
1287 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1288 let result = rule.check(&ctx).unwrap();
1289 assert!(
1290 result.is_empty(),
1291 "Should not flag blank after callout even if followed by regular blockquote"
1292 );
1293 }
1294
1295 #[test]
1299 fn test_html_comment_blockquotes_not_flagged() {
1300 let rule = MD028NoBlanksBlockquote::with_fix(true);
1301 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";
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 flag blank lines inside HTML comments, got: {result:?}"
1307 );
1308 }
1309
1310 #[test]
1311 fn test_fix_preserves_html_comment_content() {
1312 let rule = MD028NoBlanksBlockquote::with_fix(true);
1313 let content = "<!--\n> First quote\n\n> Second quote\n-->\n";
1314 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1315 let fixed = rule.fix(&ctx).unwrap();
1316 assert_eq!(fixed, content, "Fix should not modify content inside HTML comments");
1317 }
1318
1319 #[test]
1320 fn test_multiline_html_comment_with_blockquotes() {
1321 let rule = MD028NoBlanksBlockquote::with_fix(true);
1322 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";
1323 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1324 let result = rule.check(&ctx).unwrap();
1325 assert!(
1326 result.is_empty(),
1327 "Should not flag any blank lines inside HTML comments, got: {result:?}"
1328 );
1329 }
1330
1331 #[test]
1332 fn test_blockquotes_outside_html_comment_still_flagged() {
1333 let rule = MD028NoBlanksBlockquote::with_fix(true);
1334 let content = "> First quote\n\n> Second quote\n\n<!--\n> Commented quote A\n\n> Commented quote B\n-->\n";
1335 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1336 let result = rule.check(&ctx).unwrap();
1337 for w in &result {
1340 assert!(
1341 w.line < 5,
1342 "Warning at line {} should not be inside HTML comment",
1343 w.line
1344 );
1345 }
1346 assert!(
1347 !result.is_empty(),
1348 "Should still flag blank line between blockquotes outside HTML comment"
1349 );
1350 }
1351
1352 #[test]
1353 fn test_frontmatter_blockquote_like_content_not_flagged() {
1354 let rule = MD028NoBlanksBlockquote::with_fix(true);
1355 let content = "---\n> not a real blockquote\n\n> also not real\n---\n\n# Title\n";
1356 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1357 let result = rule.check(&ctx).unwrap();
1358 assert!(
1359 result.is_empty(),
1360 "Should not flag content inside frontmatter, got: {result:?}"
1361 );
1362 }
1363
1364 #[test]
1365 fn test_comment_boundary_does_not_leak_into_adjacent_blockquotes() {
1366 let rule = MD028NoBlanksBlockquote::with_fix(true);
1369 let content = "> real quote\n\n<!--\n> commented quote\n-->\n";
1370 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1371 let result = rule.check(&ctx).unwrap();
1372 assert!(
1373 result.is_empty(),
1374 "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1375 );
1376 }
1377
1378 #[test]
1379 fn test_blockquote_after_comment_boundary_not_matched() {
1380 let rule = MD028NoBlanksBlockquote::with_fix(true);
1383 let content = "<!--\n> commented quote\n-->\n\n> real quote\n";
1384 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1385 let result = rule.check(&ctx).unwrap();
1386 assert!(
1387 result.is_empty(),
1388 "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1389 );
1390 }
1391
1392 #[test]
1393 fn test_fix_preserves_comment_boundary_content() {
1394 let rule = MD028NoBlanksBlockquote::with_fix(true);
1396 let content = "> real quote\n\n<!--\n> commented quote A\n\n> commented quote B\n-->\n\n> another real quote\n";
1397 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1398 let fixed = rule.fix(&ctx).unwrap();
1399 assert_eq!(
1400 fixed, content,
1401 "Fix should not modify content when blockquotes are separated by comment boundaries"
1402 );
1403 }
1404
1405 #[test]
1406 fn test_inline_html_comment_does_not_suppress_warning() {
1407 let rule = MD028NoBlanksBlockquote::with_fix(true);
1410 let content = "> quote with <!-- inline comment -->\n\n> continuation\n";
1411 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1412 let result = rule.check(&ctx).unwrap();
1413 assert!(
1415 !result.is_empty(),
1416 "Should still flag blank lines between blockquotes with inline HTML comments"
1417 );
1418 }
1419
1420 #[test]
1426 fn test_comment_with_blockquote_markers_on_delimiters() {
1427 let rule = MD028NoBlanksBlockquote::with_fix(true);
1430 let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1431 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1432 let result = rule.check(&ctx).unwrap();
1433 assert_eq!(
1435 result.len(),
1436 1,
1437 "Should only warn about blank between real quotes, got: {result:?}"
1438 );
1439 assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1440 }
1441
1442 #[test]
1443 fn test_commented_blockquote_between_real_blockquotes() {
1444 let rule = MD028NoBlanksBlockquote::with_fix(true);
1448 let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1449 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1450 let result = rule.check(&ctx).unwrap();
1451 assert!(
1452 result.is_empty(),
1453 "Should NOT warn when non-blockquote content (HTML comment) separates blockquotes, got: {result:?}"
1454 );
1455 }
1456
1457 #[test]
1458 fn test_code_block_with_blockquote_markers_between_real_blockquotes() {
1459 let rule = MD028NoBlanksBlockquote::with_fix(true);
1461 let content = "> real A\n\n```\n> not a blockquote\n```\n\n> real B";
1462 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1463 let result = rule.check(&ctx).unwrap();
1464 assert!(
1465 result.is_empty(),
1466 "Should NOT warn when code block with > markers separates blockquotes, got: {result:?}"
1467 );
1468 }
1469
1470 #[test]
1471 fn test_frontmatter_with_blockquote_markers_does_not_cause_false_positive() {
1472 let rule = MD028NoBlanksBlockquote::with_fix(true);
1474 let content = "---\n> frontmatter value\n---\n\n> real quote A\n\n> real quote B";
1475 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1476 let result = rule.check(&ctx).unwrap();
1477 assert_eq!(
1479 result.len(),
1480 1,
1481 "Should only flag the blank between real quotes, got: {result:?}"
1482 );
1483 assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1484 }
1485
1486 #[test]
1487 fn test_fix_does_not_modify_comment_separated_blockquotes() {
1488 let rule = MD028NoBlanksBlockquote::with_fix(true);
1490 let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1491 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1492 let fixed = rule.fix(&ctx).unwrap();
1493 assert_eq!(
1494 fixed, content,
1495 "Fix should not modify content when blockquotes are separated by HTML comment"
1496 );
1497 }
1498
1499 #[test]
1500 fn test_fix_works_correctly_with_comment_before_real_blockquotes() {
1501 let rule = MD028NoBlanksBlockquote::with_fix(true);
1504 let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1505 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1506 let fixed = rule.fix(&ctx).unwrap();
1507 assert!(
1509 fixed.contains("> real quote A\n>\n> real quote B"),
1510 "Fix should add > marker between real quotes, got: {fixed}"
1511 );
1512 assert!(
1514 fixed.contains("<!-- > not a real blockquote"),
1515 "Fix should not modify comment content"
1516 );
1517 }
1518
1519 #[test]
1520 fn test_html_block_with_angle_brackets_not_flagged() {
1521 let rule = MD028NoBlanksBlockquote::with_fix(true);
1524 let content = "<div>\n> not a real blockquote\n\n> also not real\n</div>";
1525 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1526 let result = rule.check(&ctx).unwrap();
1527
1528 assert!(
1529 result.is_empty(),
1530 "Lines inside HTML blocks should not trigger MD028. Got: {result:?}"
1531 );
1532 }
1533
1534 #[test]
1538 fn test_roundtrip_single_blank() {
1539 let rule = MD028NoBlanksBlockquote::with_fix(true);
1540 let content = "> First\n\n> Third";
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_multiple_blanks() {
1553 let rule = MD028NoBlanksBlockquote::with_fix(true);
1554 let content = "> First\n\n\n> Fourth";
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_nested() {
1567 let rule = MD028NoBlanksBlockquote::with_fix(true);
1568 let content = ">> Nested\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_indented() {
1581 let rule = MD028NoBlanksBlockquote::with_fix(true);
1582 let content = " > Indented\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_deeply_nested() {
1595 let rule = MD028NoBlanksBlockquote::with_fix(true);
1596 let content = ">>> Deep\n\n>>> More";
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_multi_blockquotes() {
1609 let rule = MD028NoBlanksBlockquote::with_fix(true);
1610 let content = "> First\n> Line\n\n> Second\n> Line\n\n> Third\n";
1611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612 let fixed = rule.fix(&ctx).unwrap();
1613 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1614 let warnings = rule.check(&ctx2).unwrap();
1615 assert!(
1616 warnings.is_empty(),
1617 "Roundtrip should produce zero warnings, got: {warnings:?}"
1618 );
1619 }
1620
1621 #[test]
1622 fn test_roundtrip_idempotent() {
1623 let rule = MD028NoBlanksBlockquote::with_fix(true);
1624 let content = "> First\n\n> Second\n\n> Third\n";
1625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1626 let fixed1 = rule.fix(&ctx).unwrap();
1627 let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
1628 let fixed2 = rule.fix(&ctx2).unwrap();
1629 assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1630 }
1631
1632 #[test]
1633 fn test_html_block_does_not_leak_into_adjacent_blockquotes() {
1634 let rule = MD028NoBlanksBlockquote::with_fix(true);
1636 let content =
1637 "<details>\n<summary>Click</summary>\n> inside html block\n</details>\n\n> real quote A\n\n> real quote B";
1638 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1639 let result = rule.check(&ctx).unwrap();
1640
1641 assert_eq!(
1643 result.len(),
1644 1,
1645 "Expected 1 warning for blank between real blockquotes after HTML block. Got: {result:?}"
1646 );
1647 }
1648}