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_column_byte_range_with_length(line_num, 1, line.len()),
490 fix_content,
491 ))
492 } else {
493 None
494 },
495 });
496 }
497 }
498
499 Ok(warnings)
500 }
501
502 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
503 if !self.config.fix || self.should_skip(ctx) {
506 return Ok(ctx.content.to_string());
507 }
508 let warnings = self.check(ctx)?;
509 if warnings.is_empty() {
510 return Ok(ctx.content.to_string());
511 }
512 let warnings =
513 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
514 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
515 .map_err(crate::rule::LintError::InvalidInput)
516 }
517
518 fn category(&self) -> RuleCategory {
520 RuleCategory::Blockquote
521 }
522
523 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
525 !ctx.likely_has_blockquotes()
526 }
527
528 fn as_any(&self) -> &dyn std::any::Any {
529 self
530 }
531
532 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
533 where
534 Self: Sized,
535 {
536 let rule_config: MD028Config = load_rule_config(config);
537 Box::new(MD028NoBlanksBlockquote::with_config(rule_config))
538 }
539
540 crate::impl_rule_config_sections!(MD028Config);
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use crate::lint_context::LintContext;
547
548 #[test]
549 fn test_default_warns_but_does_not_merge_blockquotes() {
550 let rule = MD028NoBlanksBlockquote::from_config(&crate::config::Config::default());
556 let content = "> Quote by Alice.\n\n> Quote by Bob.\n";
557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
558
559 let warnings = rule.check(&ctx).unwrap();
560 assert_eq!(warnings.len(), 1, "detection should still fire by default");
561 assert!(warnings[0].fix.is_none(), "default warnings must not carry a fix");
562
563 let fixed = rule.fix(&ctx).unwrap();
564 assert_eq!(fixed, content, "default fmt must not merge distinct blockquotes");
565 }
566
567 #[test]
568 fn test_fix_enabled_merges_blockquotes() {
569 let rule = MD028NoBlanksBlockquote::with_fix(true);
572 let content = "> A quote\n\n> its continuation\n";
573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574 let fixed = rule.fix(&ctx).unwrap();
575 assert_eq!(fixed, "> A quote\n>\n> its continuation\n");
576 }
577
578 #[test]
579 fn test_no_blockquotes() {
580 let rule = MD028NoBlanksBlockquote::with_fix(true);
581 let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583 let result = rule.check(&ctx).unwrap();
584 assert!(result.is_empty(), "Should not flag content without blockquotes");
585 }
586
587 #[test]
588 fn test_valid_blockquote_no_blanks() {
589 let rule = MD028NoBlanksBlockquote::with_fix(true);
590 let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
592 let result = rule.check(&ctx).unwrap();
593 assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
594 }
595
596 #[test]
597 fn test_blockquote_with_empty_line_marker() {
598 let rule = MD028NoBlanksBlockquote::with_fix(true);
599 let content = "> First line\n>\n> Third line";
601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
602 let result = rule.check(&ctx).unwrap();
603 assert!(result.is_empty(), "Should not flag lines with just > marker");
604 }
605
606 #[test]
607 fn test_blockquote_with_empty_line_marker_and_space() {
608 let rule = MD028NoBlanksBlockquote::with_fix(true);
609 let content = "> First line\n> \n> Third line";
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
612 let result = rule.check(&ctx).unwrap();
613 assert!(result.is_empty(), "Should not flag lines with > and space");
614 }
615
616 #[test]
617 fn test_blank_line_in_blockquote() {
618 let rule = MD028NoBlanksBlockquote::with_fix(true);
619 let content = "> First line\n\n> Third line";
621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
622 let result = rule.check(&ctx).unwrap();
623 assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
624 assert_eq!(result[0].line, 2);
625 assert!(result[0].message.contains("Blank line inside blockquote"));
626 }
627
628 #[test]
629 fn test_multiple_blank_lines() {
630 let rule = MD028NoBlanksBlockquote::with_fix(true);
631 let content = "> First\n\n\n> Fourth";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633 let result = rule.check(&ctx).unwrap();
634 assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
636 assert_eq!(result[0].line, 2);
637 assert_eq!(result[1].line, 3);
638 }
639
640 #[test]
641 fn test_nested_blockquote_blank() {
642 let rule = MD028NoBlanksBlockquote::with_fix(true);
643 let content = ">> Nested quote\n\n>> More nested";
644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645 let result = rule.check(&ctx).unwrap();
646 assert_eq!(result.len(), 1);
647 assert_eq!(result[0].line, 2);
648 }
649
650 #[test]
651 fn test_nested_blockquote_with_marker() {
652 let rule = MD028NoBlanksBlockquote::with_fix(true);
653 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!(result.is_empty(), "Should not flag lines with >> marker");
658 }
659
660 #[test]
661 fn test_fix_single_blank() {
662 let rule = MD028NoBlanksBlockquote::with_fix(true);
663 let content = "> First\n\n> Third";
664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
665 let fixed = rule.fix(&ctx).unwrap();
666 assert_eq!(fixed, "> First\n>\n> Third");
667 }
668
669 #[test]
670 fn test_fix_nested_blank() {
671 let rule = MD028NoBlanksBlockquote::with_fix(true);
672 let content = ">> Nested\n\n>> More";
673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
674 let fixed = rule.fix(&ctx).unwrap();
675 assert_eq!(fixed, ">> Nested\n>>\n>> More");
676 }
677
678 #[test]
679 fn test_fix_with_indentation() {
680 let rule = MD028NoBlanksBlockquote::with_fix(true);
681 let content = " > Indented quote\n\n > More";
682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683 let fixed = rule.fix(&ctx).unwrap();
684 assert_eq!(fixed, " > Indented quote\n >\n > More");
685 }
686
687 #[test]
688 fn test_mixed_levels() {
689 let rule = MD028NoBlanksBlockquote::with_fix(true);
690 let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693 let result = rule.check(&ctx).unwrap();
694 assert_eq!(result.len(), 1);
697 assert_eq!(result[0].line, 2);
698 }
699
700 #[test]
701 fn test_blockquote_with_code_block() {
702 let rule = MD028NoBlanksBlockquote::with_fix(true);
703 let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
705 let result = rule.check(&ctx).unwrap();
706 assert!(result.is_empty(), "Should not flag line with > marker");
708 }
709
710 #[test]
711 fn test_category() {
712 let rule = MD028NoBlanksBlockquote::with_fix(true);
713 assert_eq!(rule.category(), RuleCategory::Blockquote);
714 }
715
716 #[test]
717 fn test_should_skip() {
718 let rule = MD028NoBlanksBlockquote::with_fix(true);
719 let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard, None);
720 assert!(rule.should_skip(&ctx1));
721
722 let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard, None);
723 assert!(!rule.should_skip(&ctx2));
724 }
725
726 #[test]
727 fn test_empty_content() {
728 let rule = MD028NoBlanksBlockquote::with_fix(true);
729 let content = "";
730 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
731 let result = rule.check(&ctx).unwrap();
732 assert!(result.is_empty());
733 }
734
735 #[test]
736 fn test_blank_after_blockquote() {
737 let rule = MD028NoBlanksBlockquote::with_fix(true);
738 let content = "> Quote\n\nNot a quote";
739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740 let result = rule.check(&ctx).unwrap();
741 assert!(result.is_empty(), "Blank line after blockquote ends is valid");
742 }
743
744 #[test]
745 fn test_blank_before_blockquote() {
746 let rule = MD028NoBlanksBlockquote::with_fix(true);
747 let content = "Not a quote\n\n> Quote";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let result = rule.check(&ctx).unwrap();
750 assert!(result.is_empty(), "Blank line before blockquote starts is valid");
751 }
752
753 #[test]
754 fn test_preserve_trailing_newline() {
755 let rule = MD028NoBlanksBlockquote::with_fix(true);
756 let content = "> Quote\n\n> More\n";
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758 let fixed = rule.fix(&ctx).unwrap();
759 assert!(fixed.ends_with('\n'));
760
761 let content_no_newline = "> Quote\n\n> More";
762 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
763 let fixed2 = rule.fix(&ctx2).unwrap();
764 assert!(!fixed2.ends_with('\n'));
765 }
766
767 #[test]
768 fn test_document_structure_extension() {
769 let rule = MD028NoBlanksBlockquote::with_fix(true);
770 let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard, None);
771 let result = rule.check(&ctx).unwrap();
773 assert!(result.is_empty(), "Should not flag valid blockquote");
774
775 let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard, None);
777 assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
778 }
779
780 #[test]
781 fn test_deeply_nested_blank() {
782 let rule = MD028NoBlanksBlockquote::with_fix(true);
783 let content = ">>> Deep nest\n\n>>> More deep";
784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785 let result = rule.check(&ctx).unwrap();
786 assert_eq!(result.len(), 1);
787
788 let fixed = rule.fix(&ctx).unwrap();
789 assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
790 }
791
792 #[test]
793 fn test_deeply_nested_with_marker() {
794 let rule = MD028NoBlanksBlockquote::with_fix(true);
795 let content = ">>> Deep nest\n>>>\n>>> More deep";
797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
798 let result = rule.check(&ctx).unwrap();
799 assert!(result.is_empty(), "Should not flag lines with >>> marker");
800 }
801
802 #[test]
803 fn test_complex_blockquote_structure() {
804 let rule = MD028NoBlanksBlockquote::with_fix(true);
805 let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
808 let result = rule.check(&ctx).unwrap();
809 assert!(result.is_empty(), "Should not flag line with > marker");
810 }
811
812 #[test]
813 fn test_complex_with_blank() {
814 let rule = MD028NoBlanksBlockquote::with_fix(true);
815 let content = "> Level 1\n> > Nested\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_eq!(
821 result.len(),
822 0,
823 "Blank between different nesting levels is not inside blockquote"
824 );
825 }
826
827 #[test]
834 fn test_gfm_alert_detection_note() {
835 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
836 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE] Additional text"));
837 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
838 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!note]")); assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!Note]")); }
841
842 #[test]
843 fn test_gfm_alert_detection_all_types() {
844 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
846 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!TIP]"));
847 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!IMPORTANT]"));
848 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!WARNING]"));
849 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!CAUTION]"));
850 }
851
852 #[test]
853 fn test_gfm_alert_detection_not_alert() {
854 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> Regular blockquote"));
856 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!INVALID]"));
857 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("> ")); }
863
864 #[test]
865 fn test_gfm_alerts_separated_by_blank_line() {
866 let rule = MD028NoBlanksBlockquote::with_fix(true);
868 let content = "> [!TIP]\n> Here's a github tip\n\n> [!NOTE]\n> Here's a github note";
869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
870 let result = rule.check(&ctx).unwrap();
871 assert!(result.is_empty(), "Should not flag blank line between GFM alerts");
872 }
873
874 #[test]
875 fn test_gfm_alerts_all_five_types_separated() {
876 let rule = MD028NoBlanksBlockquote::with_fix(true);
878 let content = r#"> [!NOTE]
879> Note content
880
881> [!TIP]
882> Tip content
883
884> [!IMPORTANT]
885> Important content
886
887> [!WARNING]
888> Warning content
889
890> [!CAUTION]
891> Caution content"#;
892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893 let result = rule.check(&ctx).unwrap();
894 assert!(
895 result.is_empty(),
896 "Should not flag blank lines between any GFM alert types"
897 );
898 }
899
900 #[test]
901 fn test_gfm_alert_with_multiple_lines() {
902 let rule = MD028NoBlanksBlockquote::with_fix(true);
904 let content = r#"> [!WARNING]
905> This is a warning
906> with multiple lines
907> of content
908
909> [!NOTE]
910> This is a note"#;
911 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
912 let result = rule.check(&ctx).unwrap();
913 assert!(
914 result.is_empty(),
915 "Should not flag blank line between multi-line GFM alerts"
916 );
917 }
918
919 #[test]
920 fn test_gfm_alert_followed_by_regular_blockquote() {
921 let rule = MD028NoBlanksBlockquote::with_fix(true);
923 let content = "> [!TIP]\n> A helpful tip\n\n> Regular blockquote";
924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
925 let result = rule.check(&ctx).unwrap();
926 assert!(result.is_empty(), "Should not flag blank line after GFM alert");
927 }
928
929 #[test]
930 fn test_regular_blockquote_followed_by_gfm_alert() {
931 let rule = MD028NoBlanksBlockquote::with_fix(true);
933 let content = "> Regular blockquote\n\n> [!NOTE]\n> Important note";
934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
935 let result = rule.check(&ctx).unwrap();
936 assert!(result.is_empty(), "Should not flag blank line before GFM alert");
937 }
938
939 #[test]
940 fn test_regular_blockquotes_still_flagged() {
941 let rule = MD028NoBlanksBlockquote::with_fix(true);
943 let content = "> First blockquote\n\n> Second blockquote";
944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
945 let result = rule.check(&ctx).unwrap();
946 assert_eq!(
947 result.len(),
948 1,
949 "Should still flag blank line between regular blockquotes"
950 );
951 }
952
953 #[test]
954 fn test_gfm_alert_blank_line_within_same_alert() {
955 let rule = MD028NoBlanksBlockquote::with_fix(true);
958 let content = "> [!NOTE]\n> First paragraph\n\n> Second paragraph of same note";
959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
960 let result = rule.check(&ctx).unwrap();
961 assert!(
966 result.is_empty(),
967 "GFM alert status propagates to subsequent blockquote lines"
968 );
969 }
970
971 #[test]
972 fn test_gfm_alert_case_insensitive() {
973 let rule = MD028NoBlanksBlockquote::with_fix(true);
974 let content = "> [!note]\n> lowercase\n\n> [!TIP]\n> uppercase\n\n> [!Warning]\n> mixed";
975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976 let result = rule.check(&ctx).unwrap();
977 assert!(result.is_empty(), "GFM alert detection should be case insensitive");
978 }
979
980 #[test]
981 fn test_gfm_alert_with_nested_blockquote() {
982 let rule = MD028NoBlanksBlockquote::with_fix(true);
984 let content = "> [!NOTE]\n> > Nested quote inside alert\n\n> [!TIP]\n> Tip";
985 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
986 let result = rule.check(&ctx).unwrap();
987 assert!(
988 result.is_empty(),
989 "Should not flag blank between alerts even with nested content"
990 );
991 }
992
993 #[test]
994 fn test_gfm_alert_indented() {
995 let rule = MD028NoBlanksBlockquote::with_fix(true);
996 let content = " > [!NOTE]\n > Indented note\n\n > [!TIP]\n > Indented tip";
998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
999 let result = rule.check(&ctx).unwrap();
1000 assert!(result.is_empty(), "Should not flag blank between indented GFM alerts");
1001 }
1002
1003 #[test]
1004 fn test_gfm_alert_mixed_with_regular_content() {
1005 let rule = MD028NoBlanksBlockquote::with_fix(true);
1007 let content = r#"# Heading
1008
1009Some paragraph.
1010
1011> [!NOTE]
1012> Important note
1013
1014More paragraph text.
1015
1016> [!WARNING]
1017> Be careful!
1018
1019Final text."#;
1020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1021 let result = rule.check(&ctx).unwrap();
1022 assert!(
1023 result.is_empty(),
1024 "GFM alerts in mixed document should not trigger warnings"
1025 );
1026 }
1027
1028 #[test]
1029 fn test_gfm_alert_fix_not_applied() {
1030 let rule = MD028NoBlanksBlockquote::with_fix(true);
1032 let content = "> [!TIP]\n> Tip\n\n> [!NOTE]\n> Note";
1033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1034 let fixed = rule.fix(&ctx).unwrap();
1035 assert_eq!(fixed, content, "Fix should not modify blank lines between GFM alerts");
1036 }
1037
1038 #[test]
1039 fn test_gfm_alert_multiple_blank_lines_between() {
1040 let rule = MD028NoBlanksBlockquote::with_fix(true);
1042 let content = "> [!NOTE]\n> Note\n\n\n> [!TIP]\n> Tip";
1043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1044 let result = rule.check(&ctx).unwrap();
1045 assert!(
1046 result.is_empty(),
1047 "Should not flag multiple blank lines between GFM alerts"
1048 );
1049 }
1050
1051 #[test]
1058 fn test_obsidian_callout_detection() {
1059 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]"));
1061 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!info]"));
1062 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!todo]"));
1063 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!success]"));
1064 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!question]"));
1065 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!failure]"));
1066 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!danger]"));
1067 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!bug]"));
1068 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!example]"));
1069 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!quote]"));
1070 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!cite]"));
1071 }
1072
1073 #[test]
1074 fn test_obsidian_callout_custom_types() {
1075 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!custom]"));
1077 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my-callout]"));
1078 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my_callout]"));
1079 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!MyCallout]"));
1080 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!callout123]"));
1081 }
1082
1083 #[test]
1084 fn test_obsidian_callout_foldable() {
1085 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]+ Expanded"));
1087 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1088 "> [!NOTE]- Collapsed"
1089 ));
1090 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!WARNING]+"));
1091 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!TIP]-"));
1092 }
1093
1094 #[test]
1095 fn test_obsidian_callout_with_title() {
1096 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1098 "> [!NOTE] Custom Title"
1099 ));
1100 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1101 "> [!WARNING]+ Be Careful!"
1102 ));
1103 }
1104
1105 #[test]
1106 fn test_obsidian_callout_invalid() {
1107 assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1109 "> Regular blockquote"
1110 ));
1111 assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [NOTE]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1114 "Regular text [!NOTE]"
1115 )); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("")); }
1118
1119 #[test]
1120 fn test_obsidian_callouts_separated_by_blank_line() {
1121 let rule = MD028NoBlanksBlockquote::with_fix(true);
1123 let content = "> [!info]\n> Some info\n\n> [!todo]\n> A todo item";
1124 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1125 let result = rule.check(&ctx).unwrap();
1126 assert!(
1127 result.is_empty(),
1128 "Should not flag blank line between Obsidian callouts"
1129 );
1130 }
1131
1132 #[test]
1133 fn test_obsidian_custom_callouts_separated() {
1134 let rule = MD028NoBlanksBlockquote::with_fix(true);
1136 let content = "> [!my-custom]\n> Custom content\n\n> [!another_custom]\n> More content";
1137 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1138 let result = rule.check(&ctx).unwrap();
1139 assert!(
1140 result.is_empty(),
1141 "Should not flag blank line between custom Obsidian callouts"
1142 );
1143 }
1144
1145 #[test]
1146 fn test_obsidian_foldable_callouts_separated() {
1147 let rule = MD028NoBlanksBlockquote::with_fix(true);
1149 let content = "> [!NOTE]+ Expanded\n> Content\n\n> [!WARNING]- Collapsed\n> Warning content";
1150 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1151 let result = rule.check(&ctx).unwrap();
1152 assert!(
1153 result.is_empty(),
1154 "Should not flag blank line between foldable Obsidian callouts"
1155 );
1156 }
1157
1158 #[test]
1159 fn test_obsidian_custom_not_recognized_in_standard_flavor() {
1160 let rule = MD028NoBlanksBlockquote::with_fix(true);
1163 let content = "> [!info]\n> Info content\n\n> [!todo]\n> Todo content";
1164 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1165 let result = rule.check(&ctx).unwrap();
1166 assert_eq!(
1168 result.len(),
1169 1,
1170 "Custom callout types should be flagged in Standard flavor"
1171 );
1172 }
1173
1174 #[test]
1175 fn test_obsidian_gfm_alerts_work_in_both_flavors() {
1176 let rule = MD028NoBlanksBlockquote::with_fix(true);
1178 let content = "> [!NOTE]\n> Note\n\n> [!WARNING]\n> Warning";
1179
1180 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1182 let result_standard = rule.check(&ctx_standard).unwrap();
1183 assert!(result_standard.is_empty(), "GFM alerts should work in Standard flavor");
1184
1185 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1187 let result_obsidian = rule.check(&ctx_obsidian).unwrap();
1188 assert!(
1189 result_obsidian.is_empty(),
1190 "GFM alerts should also work in Obsidian flavor"
1191 );
1192 }
1193
1194 #[test]
1195 fn test_obsidian_callout_all_builtin_types() {
1196 let rule = MD028NoBlanksBlockquote::with_fix(true);
1198 let content = r#"> [!note]
1199> Note
1200
1201> [!abstract]
1202> Abstract
1203
1204> [!summary]
1205> Summary
1206
1207> [!info]
1208> Info
1209
1210> [!todo]
1211> Todo
1212
1213> [!tip]
1214> Tip
1215
1216> [!success]
1217> Success
1218
1219> [!question]
1220> Question
1221
1222> [!warning]
1223> Warning
1224
1225> [!failure]
1226> Failure
1227
1228> [!danger]
1229> Danger
1230
1231> [!bug]
1232> Bug
1233
1234> [!example]
1235> Example
1236
1237> [!quote]
1238> Quote"#;
1239 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1240 let result = rule.check(&ctx).unwrap();
1241 assert!(result.is_empty(), "All Obsidian callout types should be recognized");
1242 }
1243
1244 #[test]
1245 fn test_obsidian_fix_not_applied_to_callouts() {
1246 let rule = MD028NoBlanksBlockquote::with_fix(true);
1248 let content = "> [!info]\n> Info\n\n> [!todo]\n> Todo";
1249 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1250 let fixed = rule.fix(&ctx).unwrap();
1251 assert_eq!(
1252 fixed, content,
1253 "Fix should not modify blank lines between Obsidian callouts"
1254 );
1255 }
1256
1257 #[test]
1258 fn test_obsidian_regular_blockquotes_still_flagged() {
1259 let rule = MD028NoBlanksBlockquote::with_fix(true);
1261 let content = "> First blockquote\n\n> Second blockquote";
1262 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1263 let result = rule.check(&ctx).unwrap();
1264 assert_eq!(
1265 result.len(),
1266 1,
1267 "Regular blockquotes should still be flagged in Obsidian flavor"
1268 );
1269 }
1270
1271 #[test]
1272 fn test_obsidian_callout_mixed_with_regular_blockquote() {
1273 let rule = MD028NoBlanksBlockquote::with_fix(true);
1275 let content = "> [!note]\n> Note content\n\n> Regular blockquote";
1276 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1277 let result = rule.check(&ctx).unwrap();
1278 assert!(
1279 result.is_empty(),
1280 "Should not flag blank after callout even if followed by regular blockquote"
1281 );
1282 }
1283
1284 #[test]
1288 fn test_html_comment_blockquotes_not_flagged() {
1289 let rule = MD028NoBlanksBlockquote::with_fix(true);
1290 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";
1291 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1292 let result = rule.check(&ctx).unwrap();
1293 assert!(
1294 result.is_empty(),
1295 "Should not flag blank lines inside HTML comments, got: {result:?}"
1296 );
1297 }
1298
1299 #[test]
1300 fn test_fix_preserves_html_comment_content() {
1301 let rule = MD028NoBlanksBlockquote::with_fix(true);
1302 let content = "<!--\n> First quote\n\n> Second quote\n-->\n";
1303 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1304 let fixed = rule.fix(&ctx).unwrap();
1305 assert_eq!(fixed, content, "Fix should not modify content inside HTML comments");
1306 }
1307
1308 #[test]
1309 fn test_multiline_html_comment_with_blockquotes() {
1310 let rule = MD028NoBlanksBlockquote::with_fix(true);
1311 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";
1312 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1313 let result = rule.check(&ctx).unwrap();
1314 assert!(
1315 result.is_empty(),
1316 "Should not flag any blank lines inside HTML comments, got: {result:?}"
1317 );
1318 }
1319
1320 #[test]
1321 fn test_blockquotes_outside_html_comment_still_flagged() {
1322 let rule = MD028NoBlanksBlockquote::with_fix(true);
1323 let content = "> First quote\n\n> Second quote\n\n<!--\n> Commented quote A\n\n> Commented quote B\n-->\n";
1324 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1325 let result = rule.check(&ctx).unwrap();
1326 for w in &result {
1329 assert!(
1330 w.line < 5,
1331 "Warning at line {} should not be inside HTML comment",
1332 w.line
1333 );
1334 }
1335 assert!(
1336 !result.is_empty(),
1337 "Should still flag blank line between blockquotes outside HTML comment"
1338 );
1339 }
1340
1341 #[test]
1342 fn test_frontmatter_blockquote_like_content_not_flagged() {
1343 let rule = MD028NoBlanksBlockquote::with_fix(true);
1344 let content = "---\n> not a real blockquote\n\n> also not real\n---\n\n# Title\n";
1345 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1346 let result = rule.check(&ctx).unwrap();
1347 assert!(
1348 result.is_empty(),
1349 "Should not flag content inside frontmatter, got: {result:?}"
1350 );
1351 }
1352
1353 #[test]
1354 fn test_comment_boundary_does_not_leak_into_adjacent_blockquotes() {
1355 let rule = MD028NoBlanksBlockquote::with_fix(true);
1358 let content = "> real quote\n\n<!--\n> commented quote\n-->\n";
1359 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1360 let result = rule.check(&ctx).unwrap();
1361 assert!(
1362 result.is_empty(),
1363 "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1364 );
1365 }
1366
1367 #[test]
1368 fn test_blockquote_after_comment_boundary_not_matched() {
1369 let rule = MD028NoBlanksBlockquote::with_fix(true);
1372 let content = "<!--\n> commented quote\n-->\n\n> real quote\n";
1373 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1374 let result = rule.check(&ctx).unwrap();
1375 assert!(
1376 result.is_empty(),
1377 "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1378 );
1379 }
1380
1381 #[test]
1382 fn test_fix_preserves_comment_boundary_content() {
1383 let rule = MD028NoBlanksBlockquote::with_fix(true);
1385 let content = "> real quote\n\n<!--\n> commented quote A\n\n> commented quote B\n-->\n\n> another real quote\n";
1386 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1387 let fixed = rule.fix(&ctx).unwrap();
1388 assert_eq!(
1389 fixed, content,
1390 "Fix should not modify content when blockquotes are separated by comment boundaries"
1391 );
1392 }
1393
1394 #[test]
1395 fn test_inline_html_comment_does_not_suppress_warning() {
1396 let rule = MD028NoBlanksBlockquote::with_fix(true);
1399 let content = "> quote with <!-- inline comment -->\n\n> continuation\n";
1400 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1401 let result = rule.check(&ctx).unwrap();
1402 assert!(
1404 !result.is_empty(),
1405 "Should still flag blank lines between blockquotes with inline HTML comments"
1406 );
1407 }
1408
1409 #[test]
1415 fn test_comment_with_blockquote_markers_on_delimiters() {
1416 let rule = MD028NoBlanksBlockquote::with_fix(true);
1419 let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1420 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1421 let result = rule.check(&ctx).unwrap();
1422 assert_eq!(
1424 result.len(),
1425 1,
1426 "Should only warn about blank between real quotes, got: {result:?}"
1427 );
1428 assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1429 }
1430
1431 #[test]
1432 fn test_commented_blockquote_between_real_blockquotes() {
1433 let rule = MD028NoBlanksBlockquote::with_fix(true);
1437 let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1438 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1439 let result = rule.check(&ctx).unwrap();
1440 assert!(
1441 result.is_empty(),
1442 "Should NOT warn when non-blockquote content (HTML comment) separates blockquotes, got: {result:?}"
1443 );
1444 }
1445
1446 #[test]
1447 fn test_code_block_with_blockquote_markers_between_real_blockquotes() {
1448 let rule = MD028NoBlanksBlockquote::with_fix(true);
1450 let content = "> real A\n\n```\n> not a blockquote\n```\n\n> real B";
1451 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1452 let result = rule.check(&ctx).unwrap();
1453 assert!(
1454 result.is_empty(),
1455 "Should NOT warn when code block with > markers separates blockquotes, got: {result:?}"
1456 );
1457 }
1458
1459 #[test]
1460 fn test_frontmatter_with_blockquote_markers_does_not_cause_false_positive() {
1461 let rule = MD028NoBlanksBlockquote::with_fix(true);
1463 let content = "---\n> frontmatter value\n---\n\n> real quote A\n\n> real quote B";
1464 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1465 let result = rule.check(&ctx).unwrap();
1466 assert_eq!(
1468 result.len(),
1469 1,
1470 "Should only flag the blank between real quotes, got: {result:?}"
1471 );
1472 assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1473 }
1474
1475 #[test]
1476 fn test_fix_does_not_modify_comment_separated_blockquotes() {
1477 let rule = MD028NoBlanksBlockquote::with_fix(true);
1479 let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1480 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1481 let fixed = rule.fix(&ctx).unwrap();
1482 assert_eq!(
1483 fixed, content,
1484 "Fix should not modify content when blockquotes are separated by HTML comment"
1485 );
1486 }
1487
1488 #[test]
1489 fn test_fix_works_correctly_with_comment_before_real_blockquotes() {
1490 let rule = MD028NoBlanksBlockquote::with_fix(true);
1493 let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1494 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1495 let fixed = rule.fix(&ctx).unwrap();
1496 assert!(
1498 fixed.contains("> real quote A\n>\n> real quote B"),
1499 "Fix should add > marker between real quotes, got: {fixed}"
1500 );
1501 assert!(
1503 fixed.contains("<!-- > not a real blockquote"),
1504 "Fix should not modify comment content"
1505 );
1506 }
1507
1508 #[test]
1509 fn test_html_block_with_angle_brackets_not_flagged() {
1510 let rule = MD028NoBlanksBlockquote::with_fix(true);
1513 let content = "<div>\n> not a real blockquote\n\n> also not real\n</div>";
1514 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1515 let result = rule.check(&ctx).unwrap();
1516
1517 assert!(
1518 result.is_empty(),
1519 "Lines inside HTML blocks should not trigger MD028. Got: {result:?}"
1520 );
1521 }
1522
1523 #[test]
1527 fn test_roundtrip_single_blank() {
1528 let rule = MD028NoBlanksBlockquote::with_fix(true);
1529 let content = "> First\n\n> Third";
1530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1531 let fixed = rule.fix(&ctx).unwrap();
1532 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1533 let warnings = rule.check(&ctx2).unwrap();
1534 assert!(
1535 warnings.is_empty(),
1536 "Roundtrip should produce zero warnings, got: {warnings:?}"
1537 );
1538 }
1539
1540 #[test]
1541 fn test_roundtrip_multiple_blanks() {
1542 let rule = MD028NoBlanksBlockquote::with_fix(true);
1543 let content = "> First\n\n\n> Fourth";
1544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1545 let fixed = rule.fix(&ctx).unwrap();
1546 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1547 let warnings = rule.check(&ctx2).unwrap();
1548 assert!(
1549 warnings.is_empty(),
1550 "Roundtrip should produce zero warnings, got: {warnings:?}"
1551 );
1552 }
1553
1554 #[test]
1555 fn test_roundtrip_nested() {
1556 let rule = MD028NoBlanksBlockquote::with_fix(true);
1557 let content = ">> Nested\n\n>> More";
1558 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1559 let fixed = rule.fix(&ctx).unwrap();
1560 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1561 let warnings = rule.check(&ctx2).unwrap();
1562 assert!(
1563 warnings.is_empty(),
1564 "Roundtrip should produce zero warnings, got: {warnings:?}"
1565 );
1566 }
1567
1568 #[test]
1569 fn test_roundtrip_indented() {
1570 let rule = MD028NoBlanksBlockquote::with_fix(true);
1571 let content = " > Indented\n\n > More";
1572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1573 let fixed = rule.fix(&ctx).unwrap();
1574 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1575 let warnings = rule.check(&ctx2).unwrap();
1576 assert!(
1577 warnings.is_empty(),
1578 "Roundtrip should produce zero warnings, got: {warnings:?}"
1579 );
1580 }
1581
1582 #[test]
1583 fn test_roundtrip_deeply_nested() {
1584 let rule = MD028NoBlanksBlockquote::with_fix(true);
1585 let content = ">>> Deep\n\n>>> More";
1586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1587 let fixed = rule.fix(&ctx).unwrap();
1588 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1589 let warnings = rule.check(&ctx2).unwrap();
1590 assert!(
1591 warnings.is_empty(),
1592 "Roundtrip should produce zero warnings, got: {warnings:?}"
1593 );
1594 }
1595
1596 #[test]
1597 fn test_roundtrip_multi_blockquotes() {
1598 let rule = MD028NoBlanksBlockquote::with_fix(true);
1599 let content = "> First\n> Line\n\n> Second\n> Line\n\n> Third\n";
1600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1601 let fixed = rule.fix(&ctx).unwrap();
1602 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1603 let warnings = rule.check(&ctx2).unwrap();
1604 assert!(
1605 warnings.is_empty(),
1606 "Roundtrip should produce zero warnings, got: {warnings:?}"
1607 );
1608 }
1609
1610 #[test]
1611 fn test_roundtrip_idempotent() {
1612 let rule = MD028NoBlanksBlockquote::with_fix(true);
1613 let content = "> First\n\n> Second\n\n> Third\n";
1614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1615 let fixed1 = rule.fix(&ctx).unwrap();
1616 let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
1617 let fixed2 = rule.fix(&ctx2).unwrap();
1618 assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1619 }
1620
1621 #[test]
1622 fn test_html_block_does_not_leak_into_adjacent_blockquotes() {
1623 let rule = MD028NoBlanksBlockquote::with_fix(true);
1625 let content =
1626 "<details>\n<summary>Click</summary>\n> inside html block\n</details>\n\n> real quote A\n\n> real quote B";
1627 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1628 let result = rule.check(&ctx).unwrap();
1629
1630 assert_eq!(
1632 result.len(),
1633 1,
1634 "Expected 1 warning for blank between real blockquotes after HTML block. Got: {result:?}"
1635 );
1636 }
1637}