1use crate::config::MarkdownFlavor;
17use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
18use crate::utils::range_utils::calculate_line_range;
19
20const GFM_ALERT_TYPES: &[&str] = &["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"];
23
24#[derive(Clone)]
25pub struct MD028NoBlanksBlockquote;
26
27impl MD028NoBlanksBlockquote {
28 #[inline]
30 fn is_blockquote_line(line: &str) -> bool {
31 if !line.as_bytes().contains(&b'>') {
33 return false;
34 }
35 line.trim_start().starts_with('>')
36 }
37
38 fn get_blockquote_info(line: &str) -> (usize, usize) {
41 let bytes = line.as_bytes();
42 let mut i = 0;
43
44 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
46 i += 1;
47 }
48
49 let whitespace_end = i;
50 let mut level = 0;
51
52 while i < bytes.len() {
54 if bytes[i] == b'>' {
55 level += 1;
56 i += 1;
57 } else if bytes[i] == b' ' || bytes[i] == b'\t' {
58 i += 1;
59 } else {
60 break;
61 }
62 }
63
64 (level, whitespace_end)
65 }
66
67 fn has_content_between(lines: &[&str], start: usize, end: usize) -> bool {
70 for line in lines.iter().take(end).skip(start) {
71 let trimmed = line.trim();
72 if !trimmed.is_empty() && !trimmed.starts_with('>') {
74 return true;
75 }
76 }
77 false
78 }
79
80 #[inline]
84 fn is_gfm_alert_line(line: &str) -> bool {
85 if !line.contains("[!") {
87 return false;
88 }
89
90 let trimmed = line.trim_start();
92 if !trimmed.starts_with('>') {
93 return false;
94 }
95
96 let content = trimmed
98 .trim_start_matches('>')
99 .trim_start_matches([' ', '\t'])
100 .trim_start_matches('>')
101 .trim_start();
102
103 if !content.starts_with("[!") {
105 return false;
106 }
107
108 if let Some(end_bracket) = content.find(']') {
110 let alert_type = &content[2..end_bracket];
111 return GFM_ALERT_TYPES.iter().any(|&t| t.eq_ignore_ascii_case(alert_type));
112 }
113
114 false
115 }
116
117 #[inline]
122 fn is_obsidian_callout_line(line: &str) -> bool {
123 if !line.contains("[!") {
125 return false;
126 }
127
128 let trimmed = line.trim_start();
130 if !trimmed.starts_with('>') {
131 return false;
132 }
133
134 let content = trimmed
136 .trim_start_matches('>')
137 .trim_start_matches([' ', '\t'])
138 .trim_start_matches('>')
139 .trim_start();
140
141 if !content.starts_with("[!") {
143 return false;
144 }
145
146 if let Some(end_bracket) = content.find(']') {
148 if end_bracket > 2 {
150 let alert_type = &content[2..end_bracket];
152 return !alert_type.is_empty()
153 && alert_type.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_');
154 }
155 }
156
157 false
158 }
159
160 #[inline]
164 fn is_callout_line(line: &str, flavor: MarkdownFlavor) -> bool {
165 match flavor {
166 MarkdownFlavor::Obsidian => Self::is_obsidian_callout_line(line),
167 _ => Self::is_gfm_alert_line(line),
168 }
169 }
170
171 fn find_blockquote_start(lines: &[&str], from_idx: usize) -> Option<usize> {
174 if from_idx >= lines.len() {
175 return None;
176 }
177
178 let mut start_idx = from_idx;
180
181 for i in (0..=from_idx).rev() {
182 let line = lines[i];
183
184 if Self::is_blockquote_line(line) {
186 start_idx = i;
187 } else if line.trim().is_empty() {
188 if start_idx == from_idx && !Self::is_blockquote_line(lines[from_idx]) {
191 continue;
192 }
193 break;
195 } else {
196 break;
198 }
199 }
200
201 if Self::is_blockquote_line(lines[start_idx]) {
203 Some(start_idx)
204 } else {
205 None
206 }
207 }
208
209 fn is_callout_block(lines: &[&str], blockquote_line_idx: usize, flavor: MarkdownFlavor) -> bool {
213 if let Some(start_idx) = Self::find_blockquote_start(lines, blockquote_line_idx) {
215 return Self::is_callout_line(lines[start_idx], flavor);
217 }
218 false
219 }
220
221 fn are_likely_same_blockquote(lines: &[&str], blank_idx: usize, flavor: MarkdownFlavor) -> bool {
223 let mut prev_quote_idx = None;
235 let mut next_quote_idx = None;
236
237 for i in (0..blank_idx).rev() {
239 let line = lines[i];
240 if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
242 prev_quote_idx = Some(i);
243 break;
244 }
245 }
246
247 for (i, line) in lines.iter().enumerate().skip(blank_idx + 1) {
249 if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
251 next_quote_idx = Some(i);
252 break;
253 }
254 }
255
256 let (prev_idx, next_idx) = match (prev_quote_idx, next_quote_idx) {
257 (Some(p), Some(n)) => (p, n),
258 _ => return false,
259 };
260
261 let prev_is_callout = Self::is_callout_block(lines, prev_idx, flavor);
267 let next_is_callout = Self::is_callout_block(lines, next_idx, flavor);
268 if prev_is_callout || next_is_callout {
269 return false;
270 }
271
272 if Self::has_content_between(lines, prev_idx + 1, next_idx) {
274 return false;
275 }
276
277 let (prev_level, prev_whitespace_end) = Self::get_blockquote_info(lines[prev_idx]);
279 let (next_level, next_whitespace_end) = Self::get_blockquote_info(lines[next_idx]);
280
281 if next_level < prev_level {
284 return false;
285 }
286
287 let prev_line = lines[prev_idx];
289 let next_line = lines[next_idx];
290 let prev_indent = &prev_line[..prev_whitespace_end];
291 let next_indent = &next_line[..next_whitespace_end];
292
293 prev_indent == next_indent
296 }
297
298 fn is_problematic_blank_line(lines: &[&str], index: usize, flavor: MarkdownFlavor) -> Option<(usize, String)> {
300 let current_line = lines[index];
301
302 if !current_line.trim().is_empty() || Self::is_blockquote_line(current_line) {
304 return None;
305 }
306
307 if !Self::are_likely_same_blockquote(lines, index, flavor) {
310 return None;
311 }
312
313 for i in (0..index).rev() {
316 let line = lines[i];
317 if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
319 let (level, whitespace_end) = Self::get_blockquote_info(line);
320 let indent = &line[..whitespace_end];
321 let mut fix = String::with_capacity(indent.len() + level);
322 fix.push_str(indent);
323 for _ in 0..level {
324 fix.push('>');
325 }
326 return Some((level, fix));
327 }
328 }
329
330 None
331 }
332}
333
334impl Default for MD028NoBlanksBlockquote {
335 fn default() -> Self {
336 Self
337 }
338}
339
340impl Rule for MD028NoBlanksBlockquote {
341 fn name(&self) -> &'static str {
342 "MD028"
343 }
344
345 fn description(&self) -> &'static str {
346 "Blank line inside blockquote"
347 }
348
349 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
350 if !ctx.content.contains('>') {
352 return Ok(Vec::new());
353 }
354
355 let mut warnings = Vec::new();
356
357 let lines: Vec<&str> = ctx.content.lines().collect();
359
360 let mut blank_line_indices = Vec::new();
362 let mut has_blockquotes = false;
363
364 for (line_idx, line) in lines.iter().enumerate() {
365 if line_idx < ctx.lines.len() && ctx.lines[line_idx].in_code_block {
367 continue;
368 }
369
370 if line.trim().is_empty() {
371 blank_line_indices.push(line_idx);
372 } else if Self::is_blockquote_line(line) {
373 has_blockquotes = true;
374 }
375 }
376
377 if !has_blockquotes {
379 return Ok(Vec::new());
380 }
381
382 for &line_idx in &blank_line_indices {
384 let line_num = line_idx + 1;
385
386 if let Some((level, fix_content)) = Self::is_problematic_blank_line(&lines, line_idx, ctx.flavor) {
388 let line = lines[line_idx];
389 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
390
391 warnings.push(LintWarning {
392 rule_name: Some(self.name().to_string()),
393 message: format!("Blank line inside blockquote (level {level})"),
394 line: start_line,
395 column: start_col,
396 end_line,
397 end_column: end_col,
398 severity: Severity::Warning,
399 fix: Some(Fix {
400 range: ctx
401 .line_index
402 .line_col_to_byte_range_with_length(line_num, 1, line.len()),
403 replacement: fix_content,
404 }),
405 });
406 }
407 }
408
409 Ok(warnings)
410 }
411
412 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
413 let mut result = Vec::with_capacity(ctx.lines.len());
414 let lines: Vec<&str> = ctx.content.lines().collect();
415
416 for (line_idx, line) in lines.iter().enumerate() {
417 if let Some((_, fix_content)) = Self::is_problematic_blank_line(&lines, line_idx, ctx.flavor) {
419 result.push(fix_content);
420 } else {
421 result.push(line.to_string());
422 }
423 }
424
425 Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
426 }
427
428 fn category(&self) -> RuleCategory {
430 RuleCategory::Blockquote
431 }
432
433 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
435 !ctx.likely_has_blockquotes()
436 }
437
438 fn as_any(&self) -> &dyn std::any::Any {
439 self
440 }
441
442 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
443 where
444 Self: Sized,
445 {
446 Box::new(MD028NoBlanksBlockquote)
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use crate::lint_context::LintContext;
454
455 #[test]
456 fn test_no_blockquotes() {
457 let rule = MD028NoBlanksBlockquote;
458 let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460 let result = rule.check(&ctx).unwrap();
461 assert!(result.is_empty(), "Should not flag content without blockquotes");
462 }
463
464 #[test]
465 fn test_valid_blockquote_no_blanks() {
466 let rule = MD028NoBlanksBlockquote;
467 let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
469 let result = rule.check(&ctx).unwrap();
470 assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
471 }
472
473 #[test]
474 fn test_blockquote_with_empty_line_marker() {
475 let rule = MD028NoBlanksBlockquote;
476 let content = "> First line\n>\n> Third line";
478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479 let result = rule.check(&ctx).unwrap();
480 assert!(result.is_empty(), "Should not flag lines with just > marker");
481 }
482
483 #[test]
484 fn test_blockquote_with_empty_line_marker_and_space() {
485 let rule = MD028NoBlanksBlockquote;
486 let content = "> First line\n> \n> Third line";
488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
489 let result = rule.check(&ctx).unwrap();
490 assert!(result.is_empty(), "Should not flag lines with > and space");
491 }
492
493 #[test]
494 fn test_blank_line_in_blockquote() {
495 let rule = MD028NoBlanksBlockquote;
496 let content = "> First line\n\n> Third line";
498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
499 let result = rule.check(&ctx).unwrap();
500 assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
501 assert_eq!(result[0].line, 2);
502 assert!(result[0].message.contains("Blank line inside blockquote"));
503 }
504
505 #[test]
506 fn test_multiple_blank_lines() {
507 let rule = MD028NoBlanksBlockquote;
508 let content = "> First\n\n\n> Fourth";
509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
510 let result = rule.check(&ctx).unwrap();
511 assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
513 assert_eq!(result[0].line, 2);
514 assert_eq!(result[1].line, 3);
515 }
516
517 #[test]
518 fn test_nested_blockquote_blank() {
519 let rule = MD028NoBlanksBlockquote;
520 let content = ">> Nested quote\n\n>> More nested";
521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
522 let result = rule.check(&ctx).unwrap();
523 assert_eq!(result.len(), 1);
524 assert_eq!(result[0].line, 2);
525 }
526
527 #[test]
528 fn test_nested_blockquote_with_marker() {
529 let rule = MD028NoBlanksBlockquote;
530 let content = ">> Nested quote\n>>\n>> More nested";
532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533 let result = rule.check(&ctx).unwrap();
534 assert!(result.is_empty(), "Should not flag lines with >> marker");
535 }
536
537 #[test]
538 fn test_fix_single_blank() {
539 let rule = MD028NoBlanksBlockquote;
540 let content = "> First\n\n> Third";
541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542 let fixed = rule.fix(&ctx).unwrap();
543 assert_eq!(fixed, "> First\n>\n> Third");
544 }
545
546 #[test]
547 fn test_fix_nested_blank() {
548 let rule = MD028NoBlanksBlockquote;
549 let content = ">> Nested\n\n>> More";
550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551 let fixed = rule.fix(&ctx).unwrap();
552 assert_eq!(fixed, ">> Nested\n>>\n>> More");
553 }
554
555 #[test]
556 fn test_fix_with_indentation() {
557 let rule = MD028NoBlanksBlockquote;
558 let content = " > Indented quote\n\n > More";
559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
560 let fixed = rule.fix(&ctx).unwrap();
561 assert_eq!(fixed, " > Indented quote\n >\n > More");
562 }
563
564 #[test]
565 fn test_mixed_levels() {
566 let rule = MD028NoBlanksBlockquote;
567 let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
570 let result = rule.check(&ctx).unwrap();
571 assert_eq!(result.len(), 1);
574 assert_eq!(result[0].line, 2);
575 }
576
577 #[test]
578 fn test_blockquote_with_code_block() {
579 let rule = MD028NoBlanksBlockquote;
580 let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582 let result = rule.check(&ctx).unwrap();
583 assert!(result.is_empty(), "Should not flag line with > marker");
585 }
586
587 #[test]
588 fn test_category() {
589 let rule = MD028NoBlanksBlockquote;
590 assert_eq!(rule.category(), RuleCategory::Blockquote);
591 }
592
593 #[test]
594 fn test_should_skip() {
595 let rule = MD028NoBlanksBlockquote;
596 let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard, None);
597 assert!(rule.should_skip(&ctx1));
598
599 let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard, None);
600 assert!(!rule.should_skip(&ctx2));
601 }
602
603 #[test]
604 fn test_empty_content() {
605 let rule = MD028NoBlanksBlockquote;
606 let content = "";
607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608 let result = rule.check(&ctx).unwrap();
609 assert!(result.is_empty());
610 }
611
612 #[test]
613 fn test_blank_after_blockquote() {
614 let rule = MD028NoBlanksBlockquote;
615 let content = "> Quote\n\nNot a quote";
616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
617 let result = rule.check(&ctx).unwrap();
618 assert!(result.is_empty(), "Blank line after blockquote ends is valid");
619 }
620
621 #[test]
622 fn test_blank_before_blockquote() {
623 let rule = MD028NoBlanksBlockquote;
624 let content = "Not a quote\n\n> Quote";
625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
626 let result = rule.check(&ctx).unwrap();
627 assert!(result.is_empty(), "Blank line before blockquote starts is valid");
628 }
629
630 #[test]
631 fn test_preserve_trailing_newline() {
632 let rule = MD028NoBlanksBlockquote;
633 let content = "> Quote\n\n> More\n";
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635 let fixed = rule.fix(&ctx).unwrap();
636 assert!(fixed.ends_with('\n'));
637
638 let content_no_newline = "> Quote\n\n> More";
639 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
640 let fixed2 = rule.fix(&ctx2).unwrap();
641 assert!(!fixed2.ends_with('\n'));
642 }
643
644 #[test]
645 fn test_document_structure_extension() {
646 let rule = MD028NoBlanksBlockquote;
647 let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard, None);
648 let result = rule.check(&ctx).unwrap();
650 assert!(result.is_empty(), "Should not flag valid blockquote");
651
652 let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard, None);
654 assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
655 }
656
657 #[test]
658 fn test_deeply_nested_blank() {
659 let rule = MD028NoBlanksBlockquote;
660 let content = ">>> Deep nest\n\n>>> More deep";
661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
662 let result = rule.check(&ctx).unwrap();
663 assert_eq!(result.len(), 1);
664
665 let fixed = rule.fix(&ctx).unwrap();
666 assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
667 }
668
669 #[test]
670 fn test_deeply_nested_with_marker() {
671 let rule = MD028NoBlanksBlockquote;
672 let content = ">>> Deep nest\n>>>\n>>> More deep";
674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675 let result = rule.check(&ctx).unwrap();
676 assert!(result.is_empty(), "Should not flag lines with >>> marker");
677 }
678
679 #[test]
680 fn test_complex_blockquote_structure() {
681 let rule = MD028NoBlanksBlockquote;
682 let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685 let result = rule.check(&ctx).unwrap();
686 assert!(result.is_empty(), "Should not flag line with > marker");
687 }
688
689 #[test]
690 fn test_complex_with_blank() {
691 let rule = MD028NoBlanksBlockquote;
692 let content = "> Level 1\n> > Nested\n\n> Back to level 1";
695 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
696 let result = rule.check(&ctx).unwrap();
697 assert_eq!(
698 result.len(),
699 0,
700 "Blank between different nesting levels is not inside blockquote"
701 );
702 }
703
704 #[test]
711 fn test_gfm_alert_detection_note() {
712 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
713 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE] Additional text"));
714 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
715 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!note]")); assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!Note]")); }
718
719 #[test]
720 fn test_gfm_alert_detection_all_types() {
721 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
723 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!TIP]"));
724 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!IMPORTANT]"));
725 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!WARNING]"));
726 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!CAUTION]"));
727 }
728
729 #[test]
730 fn test_gfm_alert_detection_not_alert() {
731 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> Regular blockquote"));
733 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!INVALID]"));
734 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("> ")); }
740
741 #[test]
742 fn test_gfm_alerts_separated_by_blank_line() {
743 let rule = MD028NoBlanksBlockquote;
745 let content = "> [!TIP]\n> Here's a github tip\n\n> [!NOTE]\n> Here's a github note";
746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
747 let result = rule.check(&ctx).unwrap();
748 assert!(result.is_empty(), "Should not flag blank line between GFM alerts");
749 }
750
751 #[test]
752 fn test_gfm_alerts_all_five_types_separated() {
753 let rule = MD028NoBlanksBlockquote;
755 let content = r#"> [!NOTE]
756> Note content
757
758> [!TIP]
759> Tip content
760
761> [!IMPORTANT]
762> Important content
763
764> [!WARNING]
765> Warning content
766
767> [!CAUTION]
768> Caution content"#;
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let result = rule.check(&ctx).unwrap();
771 assert!(
772 result.is_empty(),
773 "Should not flag blank lines between any GFM alert types"
774 );
775 }
776
777 #[test]
778 fn test_gfm_alert_with_multiple_lines() {
779 let rule = MD028NoBlanksBlockquote;
781 let content = r#"> [!WARNING]
782> This is a warning
783> with multiple lines
784> of content
785
786> [!NOTE]
787> This is a note"#;
788 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
789 let result = rule.check(&ctx).unwrap();
790 assert!(
791 result.is_empty(),
792 "Should not flag blank line between multi-line GFM alerts"
793 );
794 }
795
796 #[test]
797 fn test_gfm_alert_followed_by_regular_blockquote() {
798 let rule = MD028NoBlanksBlockquote;
800 let content = "> [!TIP]\n> A helpful tip\n\n> Regular blockquote";
801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
802 let result = rule.check(&ctx).unwrap();
803 assert!(result.is_empty(), "Should not flag blank line after GFM alert");
804 }
805
806 #[test]
807 fn test_regular_blockquote_followed_by_gfm_alert() {
808 let rule = MD028NoBlanksBlockquote;
810 let content = "> Regular blockquote\n\n> [!NOTE]\n> Important note";
811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812 let result = rule.check(&ctx).unwrap();
813 assert!(result.is_empty(), "Should not flag blank line before GFM alert");
814 }
815
816 #[test]
817 fn test_regular_blockquotes_still_flagged() {
818 let rule = MD028NoBlanksBlockquote;
820 let content = "> First blockquote\n\n> Second blockquote";
821 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
822 let result = rule.check(&ctx).unwrap();
823 assert_eq!(
824 result.len(),
825 1,
826 "Should still flag blank line between regular blockquotes"
827 );
828 }
829
830 #[test]
831 fn test_gfm_alert_blank_line_within_same_alert() {
832 let rule = MD028NoBlanksBlockquote;
835 let content = "> [!NOTE]\n> First paragraph\n\n> Second paragraph of same note";
836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838 assert!(
843 result.is_empty(),
844 "GFM alert status propagates to subsequent blockquote lines"
845 );
846 }
847
848 #[test]
849 fn test_gfm_alert_case_insensitive() {
850 let rule = MD028NoBlanksBlockquote;
851 let content = "> [!note]\n> lowercase\n\n> [!TIP]\n> uppercase\n\n> [!Warning]\n> mixed";
852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853 let result = rule.check(&ctx).unwrap();
854 assert!(result.is_empty(), "GFM alert detection should be case insensitive");
855 }
856
857 #[test]
858 fn test_gfm_alert_with_nested_blockquote() {
859 let rule = MD028NoBlanksBlockquote;
861 let content = "> [!NOTE]\n> > Nested quote inside alert\n\n> [!TIP]\n> Tip";
862 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
863 let result = rule.check(&ctx).unwrap();
864 assert!(
865 result.is_empty(),
866 "Should not flag blank between alerts even with nested content"
867 );
868 }
869
870 #[test]
871 fn test_gfm_alert_indented() {
872 let rule = MD028NoBlanksBlockquote;
873 let content = " > [!NOTE]\n > Indented note\n\n > [!TIP]\n > Indented tip";
875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876 let result = rule.check(&ctx).unwrap();
877 assert!(result.is_empty(), "Should not flag blank between indented GFM alerts");
878 }
879
880 #[test]
881 fn test_gfm_alert_mixed_with_regular_content() {
882 let rule = MD028NoBlanksBlockquote;
884 let content = r#"# Heading
885
886Some paragraph.
887
888> [!NOTE]
889> Important note
890
891More paragraph text.
892
893> [!WARNING]
894> Be careful!
895
896Final text."#;
897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898 let result = rule.check(&ctx).unwrap();
899 assert!(
900 result.is_empty(),
901 "GFM alerts in mixed document should not trigger warnings"
902 );
903 }
904
905 #[test]
906 fn test_gfm_alert_fix_not_applied() {
907 let rule = MD028NoBlanksBlockquote;
909 let content = "> [!TIP]\n> Tip\n\n> [!NOTE]\n> Note";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911 let fixed = rule.fix(&ctx).unwrap();
912 assert_eq!(fixed, content, "Fix should not modify blank lines between GFM alerts");
913 }
914
915 #[test]
916 fn test_gfm_alert_multiple_blank_lines_between() {
917 let rule = MD028NoBlanksBlockquote;
919 let content = "> [!NOTE]\n> Note\n\n\n> [!TIP]\n> Tip";
920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
921 let result = rule.check(&ctx).unwrap();
922 assert!(
923 result.is_empty(),
924 "Should not flag multiple blank lines between GFM alerts"
925 );
926 }
927
928 #[test]
935 fn test_obsidian_callout_detection() {
936 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]"));
938 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!info]"));
939 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!todo]"));
940 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!success]"));
941 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!question]"));
942 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!failure]"));
943 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!danger]"));
944 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!bug]"));
945 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!example]"));
946 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!quote]"));
947 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!cite]"));
948 }
949
950 #[test]
951 fn test_obsidian_callout_custom_types() {
952 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!custom]"));
954 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my-callout]"));
955 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my_callout]"));
956 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!MyCallout]"));
957 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!callout123]"));
958 }
959
960 #[test]
961 fn test_obsidian_callout_foldable() {
962 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]+ Expanded"));
964 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
965 "> [!NOTE]- Collapsed"
966 ));
967 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!WARNING]+"));
968 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!TIP]-"));
969 }
970
971 #[test]
972 fn test_obsidian_callout_with_title() {
973 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
975 "> [!NOTE] Custom Title"
976 ));
977 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
978 "> [!WARNING]+ Be Careful!"
979 ));
980 }
981
982 #[test]
983 fn test_obsidian_callout_invalid() {
984 assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
986 "> Regular blockquote"
987 ));
988 assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [NOTE]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
991 "Regular text [!NOTE]"
992 )); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("")); }
995
996 #[test]
997 fn test_obsidian_callouts_separated_by_blank_line() {
998 let rule = MD028NoBlanksBlockquote;
1000 let content = "> [!info]\n> Some info\n\n> [!todo]\n> A todo item";
1001 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1002 let result = rule.check(&ctx).unwrap();
1003 assert!(
1004 result.is_empty(),
1005 "Should not flag blank line between Obsidian callouts"
1006 );
1007 }
1008
1009 #[test]
1010 fn test_obsidian_custom_callouts_separated() {
1011 let rule = MD028NoBlanksBlockquote;
1013 let content = "> [!my-custom]\n> Custom content\n\n> [!another_custom]\n> More content";
1014 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1015 let result = rule.check(&ctx).unwrap();
1016 assert!(
1017 result.is_empty(),
1018 "Should not flag blank line between custom Obsidian callouts"
1019 );
1020 }
1021
1022 #[test]
1023 fn test_obsidian_foldable_callouts_separated() {
1024 let rule = MD028NoBlanksBlockquote;
1026 let content = "> [!NOTE]+ Expanded\n> Content\n\n> [!WARNING]- Collapsed\n> Warning content";
1027 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1028 let result = rule.check(&ctx).unwrap();
1029 assert!(
1030 result.is_empty(),
1031 "Should not flag blank line between foldable Obsidian callouts"
1032 );
1033 }
1034
1035 #[test]
1036 fn test_obsidian_custom_not_recognized_in_standard_flavor() {
1037 let rule = MD028NoBlanksBlockquote;
1040 let content = "> [!info]\n> Info content\n\n> [!todo]\n> Todo content";
1041 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1042 let result = rule.check(&ctx).unwrap();
1043 assert_eq!(
1045 result.len(),
1046 1,
1047 "Custom callout types should be flagged in Standard flavor"
1048 );
1049 }
1050
1051 #[test]
1052 fn test_obsidian_gfm_alerts_work_in_both_flavors() {
1053 let rule = MD028NoBlanksBlockquote;
1055 let content = "> [!NOTE]\n> Note\n\n> [!WARNING]\n> Warning";
1056
1057 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1059 let result_standard = rule.check(&ctx_standard).unwrap();
1060 assert!(result_standard.is_empty(), "GFM alerts should work in Standard flavor");
1061
1062 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1064 let result_obsidian = rule.check(&ctx_obsidian).unwrap();
1065 assert!(
1066 result_obsidian.is_empty(),
1067 "GFM alerts should also work in Obsidian flavor"
1068 );
1069 }
1070
1071 #[test]
1072 fn test_obsidian_callout_all_builtin_types() {
1073 let rule = MD028NoBlanksBlockquote;
1075 let content = r#"> [!note]
1076> Note
1077
1078> [!abstract]
1079> Abstract
1080
1081> [!summary]
1082> Summary
1083
1084> [!info]
1085> Info
1086
1087> [!todo]
1088> Todo
1089
1090> [!tip]
1091> Tip
1092
1093> [!success]
1094> Success
1095
1096> [!question]
1097> Question
1098
1099> [!warning]
1100> Warning
1101
1102> [!failure]
1103> Failure
1104
1105> [!danger]
1106> Danger
1107
1108> [!bug]
1109> Bug
1110
1111> [!example]
1112> Example
1113
1114> [!quote]
1115> Quote"#;
1116 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1117 let result = rule.check(&ctx).unwrap();
1118 assert!(result.is_empty(), "All Obsidian callout types should be recognized");
1119 }
1120
1121 #[test]
1122 fn test_obsidian_fix_not_applied_to_callouts() {
1123 let rule = MD028NoBlanksBlockquote;
1125 let content = "> [!info]\n> Info\n\n> [!todo]\n> Todo";
1126 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1127 let fixed = rule.fix(&ctx).unwrap();
1128 assert_eq!(
1129 fixed, content,
1130 "Fix should not modify blank lines between Obsidian callouts"
1131 );
1132 }
1133
1134 #[test]
1135 fn test_obsidian_regular_blockquotes_still_flagged() {
1136 let rule = MD028NoBlanksBlockquote;
1138 let content = "> First blockquote\n\n> Second blockquote";
1139 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1140 let result = rule.check(&ctx).unwrap();
1141 assert_eq!(
1142 result.len(),
1143 1,
1144 "Regular blockquotes should still be flagged in Obsidian flavor"
1145 );
1146 }
1147
1148 #[test]
1149 fn test_obsidian_callout_mixed_with_regular_blockquote() {
1150 let rule = MD028NoBlanksBlockquote;
1152 let content = "> [!note]\n> Note content\n\n> Regular blockquote";
1153 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1154 let result = rule.check(&ctx).unwrap();
1155 assert!(
1156 result.is_empty(),
1157 "Should not flag blank after callout even if followed by regular blockquote"
1158 );
1159 }
1160}