1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
13use crate::utils::range_utils::calculate_line_range;
14
15const GFM_ALERT_TYPES: &[&str] = &["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"];
18
19#[derive(Clone)]
20pub struct MD028NoBlanksBlockquote;
21
22impl MD028NoBlanksBlockquote {
23 #[inline]
25 fn is_blockquote_line(line: &str) -> bool {
26 if !line.as_bytes().contains(&b'>') {
28 return false;
29 }
30 line.trim_start().starts_with('>')
31 }
32
33 fn get_blockquote_info(line: &str) -> (usize, usize) {
36 let bytes = line.as_bytes();
37 let mut i = 0;
38
39 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
41 i += 1;
42 }
43
44 let whitespace_end = i;
45 let mut level = 0;
46
47 while i < bytes.len() {
49 if bytes[i] == b'>' {
50 level += 1;
51 i += 1;
52 } else if bytes[i] == b' ' || bytes[i] == b'\t' {
53 i += 1;
54 } else {
55 break;
56 }
57 }
58
59 (level, whitespace_end)
60 }
61
62 fn has_content_between(lines: &[&str], start: usize, end: usize) -> bool {
65 for line in lines.iter().take(end).skip(start) {
66 let trimmed = line.trim();
67 if !trimmed.is_empty() && !trimmed.starts_with('>') {
69 return true;
70 }
71 }
72 false
73 }
74
75 #[inline]
79 fn is_gfm_alert_line(line: &str) -> bool {
80 if !line.contains("[!") {
82 return false;
83 }
84
85 let trimmed = line.trim_start();
87 if !trimmed.starts_with('>') {
88 return false;
89 }
90
91 let content = trimmed
93 .trim_start_matches('>')
94 .trim_start_matches([' ', '\t'])
95 .trim_start_matches('>')
96 .trim_start();
97
98 if !content.starts_with("[!") {
100 return false;
101 }
102
103 if let Some(end_bracket) = content.find(']') {
105 let alert_type = &content[2..end_bracket];
106 return GFM_ALERT_TYPES.iter().any(|&t| t.eq_ignore_ascii_case(alert_type));
107 }
108
109 false
110 }
111
112 fn find_blockquote_start(lines: &[&str], from_idx: usize) -> Option<usize> {
115 if from_idx >= lines.len() {
116 return None;
117 }
118
119 let mut start_idx = from_idx;
121
122 for i in (0..=from_idx).rev() {
123 let line = lines[i];
124
125 if Self::is_blockquote_line(line) {
127 start_idx = i;
128 } else if line.trim().is_empty() {
129 if start_idx == from_idx && !Self::is_blockquote_line(lines[from_idx]) {
132 continue;
133 }
134 break;
136 } else {
137 break;
139 }
140 }
141
142 if Self::is_blockquote_line(lines[start_idx]) {
144 Some(start_idx)
145 } else {
146 None
147 }
148 }
149
150 fn is_gfm_alert_block(lines: &[&str], blockquote_line_idx: usize) -> bool {
152 if let Some(start_idx) = Self::find_blockquote_start(lines, blockquote_line_idx) {
154 return Self::is_gfm_alert_line(lines[start_idx]);
156 }
157 false
158 }
159
160 fn are_likely_same_blockquote(lines: &[&str], blank_idx: usize) -> bool {
162 let mut prev_quote_idx = None;
174 let mut next_quote_idx = None;
175
176 for i in (0..blank_idx).rev() {
178 let line = lines[i];
179 if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
181 prev_quote_idx = Some(i);
182 break;
183 }
184 }
185
186 for (i, line) in lines.iter().enumerate().skip(blank_idx + 1) {
188 if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
190 next_quote_idx = Some(i);
191 break;
192 }
193 }
194
195 let (prev_idx, next_idx) = match (prev_quote_idx, next_quote_idx) {
196 (Some(p), Some(n)) => (p, n),
197 _ => return false,
198 };
199
200 let prev_is_alert = Self::is_gfm_alert_block(lines, prev_idx);
204 let next_is_alert = Self::is_gfm_alert_block(lines, next_idx);
205 if prev_is_alert || next_is_alert {
206 return false;
207 }
208
209 if Self::has_content_between(lines, prev_idx + 1, next_idx) {
211 return false;
212 }
213
214 let (prev_level, prev_whitespace_end) = Self::get_blockquote_info(lines[prev_idx]);
216 let (next_level, next_whitespace_end) = Self::get_blockquote_info(lines[next_idx]);
217
218 if next_level < prev_level {
221 return false;
222 }
223
224 let prev_line = lines[prev_idx];
226 let next_line = lines[next_idx];
227 let prev_indent = &prev_line[..prev_whitespace_end];
228 let next_indent = &next_line[..next_whitespace_end];
229
230 prev_indent == next_indent
233 }
234
235 fn is_problematic_blank_line(lines: &[&str], index: usize) -> Option<(usize, String)> {
237 let current_line = lines[index];
238
239 if !current_line.trim().is_empty() || Self::is_blockquote_line(current_line) {
241 return None;
242 }
243
244 if !Self::are_likely_same_blockquote(lines, index) {
247 return None;
248 }
249
250 for i in (0..index).rev() {
253 let line = lines[i];
254 if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
256 let (level, whitespace_end) = Self::get_blockquote_info(line);
257 let indent = &line[..whitespace_end];
258 let mut fix = String::with_capacity(indent.len() + level);
259 fix.push_str(indent);
260 for _ in 0..level {
261 fix.push('>');
262 }
263 return Some((level, fix));
264 }
265 }
266
267 None
268 }
269}
270
271impl Default for MD028NoBlanksBlockquote {
272 fn default() -> Self {
273 Self
274 }
275}
276
277impl Rule for MD028NoBlanksBlockquote {
278 fn name(&self) -> &'static str {
279 "MD028"
280 }
281
282 fn description(&self) -> &'static str {
283 "Blank line inside blockquote"
284 }
285
286 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
287 if !ctx.content.contains('>') {
289 return Ok(Vec::new());
290 }
291
292 let mut warnings = Vec::new();
293
294 let lines: Vec<&str> = ctx.content.lines().collect();
296
297 let mut blank_line_indices = Vec::new();
299 let mut has_blockquotes = false;
300
301 for (line_idx, line) in lines.iter().enumerate() {
302 if line_idx < ctx.lines.len() && ctx.lines[line_idx].in_code_block {
304 continue;
305 }
306
307 if line.trim().is_empty() {
308 blank_line_indices.push(line_idx);
309 } else if Self::is_blockquote_line(line) {
310 has_blockquotes = true;
311 }
312 }
313
314 if !has_blockquotes {
316 return Ok(Vec::new());
317 }
318
319 for &line_idx in &blank_line_indices {
321 let line_num = line_idx + 1;
322
323 if let Some((level, fix_content)) = Self::is_problematic_blank_line(&lines, line_idx) {
325 let line = lines[line_idx];
326 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
327
328 warnings.push(LintWarning {
329 rule_name: Some(self.name().to_string()),
330 message: format!("Blank line inside blockquote (level {level})"),
331 line: start_line,
332 column: start_col,
333 end_line,
334 end_column: end_col,
335 severity: Severity::Warning,
336 fix: Some(Fix {
337 range: ctx
338 .line_index
339 .line_col_to_byte_range_with_length(line_num, 1, line.len()),
340 replacement: fix_content,
341 }),
342 });
343 }
344 }
345
346 Ok(warnings)
347 }
348
349 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
350 let mut result = Vec::with_capacity(ctx.lines.len());
351 let lines: Vec<&str> = ctx.content.lines().collect();
352
353 for (line_idx, line) in lines.iter().enumerate() {
354 if let Some((_, fix_content)) = Self::is_problematic_blank_line(&lines, line_idx) {
356 result.push(fix_content);
357 } else {
358 result.push(line.to_string());
359 }
360 }
361
362 Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
363 }
364
365 fn category(&self) -> RuleCategory {
367 RuleCategory::Blockquote
368 }
369
370 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
372 !ctx.likely_has_blockquotes()
373 }
374
375 fn as_any(&self) -> &dyn std::any::Any {
376 self
377 }
378
379 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
380 where
381 Self: Sized,
382 {
383 Box::new(MD028NoBlanksBlockquote)
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::lint_context::LintContext;
391
392 #[test]
393 fn test_no_blockquotes() {
394 let rule = MD028NoBlanksBlockquote;
395 let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
397 let result = rule.check(&ctx).unwrap();
398 assert!(result.is_empty(), "Should not flag content without blockquotes");
399 }
400
401 #[test]
402 fn test_valid_blockquote_no_blanks() {
403 let rule = MD028NoBlanksBlockquote;
404 let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
406 let result = rule.check(&ctx).unwrap();
407 assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
408 }
409
410 #[test]
411 fn test_blockquote_with_empty_line_marker() {
412 let rule = MD028NoBlanksBlockquote;
413 let content = "> First line\n>\n> Third line";
415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
416 let result = rule.check(&ctx).unwrap();
417 assert!(result.is_empty(), "Should not flag lines with just > marker");
418 }
419
420 #[test]
421 fn test_blockquote_with_empty_line_marker_and_space() {
422 let rule = MD028NoBlanksBlockquote;
423 let content = "> First line\n> \n> Third line";
425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426 let result = rule.check(&ctx).unwrap();
427 assert!(result.is_empty(), "Should not flag lines with > and space");
428 }
429
430 #[test]
431 fn test_blank_line_in_blockquote() {
432 let rule = MD028NoBlanksBlockquote;
433 let content = "> First line\n\n> Third line";
435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
436 let result = rule.check(&ctx).unwrap();
437 assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
438 assert_eq!(result[0].line, 2);
439 assert!(result[0].message.contains("Blank line inside blockquote"));
440 }
441
442 #[test]
443 fn test_multiple_blank_lines() {
444 let rule = MD028NoBlanksBlockquote;
445 let content = "> First\n\n\n> Fourth";
446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
447 let result = rule.check(&ctx).unwrap();
448 assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
450 assert_eq!(result[0].line, 2);
451 assert_eq!(result[1].line, 3);
452 }
453
454 #[test]
455 fn test_nested_blockquote_blank() {
456 let rule = MD028NoBlanksBlockquote;
457 let content = ">> Nested quote\n\n>> More nested";
458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
459 let result = rule.check(&ctx).unwrap();
460 assert_eq!(result.len(), 1);
461 assert_eq!(result[0].line, 2);
462 }
463
464 #[test]
465 fn test_nested_blockquote_with_marker() {
466 let rule = MD028NoBlanksBlockquote;
467 let content = ">> Nested quote\n>>\n>> More nested";
469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
470 let result = rule.check(&ctx).unwrap();
471 assert!(result.is_empty(), "Should not flag lines with >> marker");
472 }
473
474 #[test]
475 fn test_fix_single_blank() {
476 let rule = MD028NoBlanksBlockquote;
477 let content = "> First\n\n> Third";
478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479 let fixed = rule.fix(&ctx).unwrap();
480 assert_eq!(fixed, "> First\n>\n> Third");
481 }
482
483 #[test]
484 fn test_fix_nested_blank() {
485 let rule = MD028NoBlanksBlockquote;
486 let content = ">> Nested\n\n>> More";
487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
488 let fixed = rule.fix(&ctx).unwrap();
489 assert_eq!(fixed, ">> Nested\n>>\n>> More");
490 }
491
492 #[test]
493 fn test_fix_with_indentation() {
494 let rule = MD028NoBlanksBlockquote;
495 let content = " > Indented quote\n\n > More";
496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497 let fixed = rule.fix(&ctx).unwrap();
498 assert_eq!(fixed, " > Indented quote\n >\n > More");
499 }
500
501 #[test]
502 fn test_mixed_levels() {
503 let rule = MD028NoBlanksBlockquote;
504 let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507 let result = rule.check(&ctx).unwrap();
508 assert_eq!(result.len(), 1);
511 assert_eq!(result[0].line, 2);
512 }
513
514 #[test]
515 fn test_blockquote_with_code_block() {
516 let rule = MD028NoBlanksBlockquote;
517 let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
519 let result = rule.check(&ctx).unwrap();
520 assert!(result.is_empty(), "Should not flag line with > marker");
522 }
523
524 #[test]
525 fn test_category() {
526 let rule = MD028NoBlanksBlockquote;
527 assert_eq!(rule.category(), RuleCategory::Blockquote);
528 }
529
530 #[test]
531 fn test_should_skip() {
532 let rule = MD028NoBlanksBlockquote;
533 let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard, None);
534 assert!(rule.should_skip(&ctx1));
535
536 let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard, None);
537 assert!(!rule.should_skip(&ctx2));
538 }
539
540 #[test]
541 fn test_empty_content() {
542 let rule = MD028NoBlanksBlockquote;
543 let content = "";
544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
545 let result = rule.check(&ctx).unwrap();
546 assert!(result.is_empty());
547 }
548
549 #[test]
550 fn test_blank_after_blockquote() {
551 let rule = MD028NoBlanksBlockquote;
552 let content = "> Quote\n\nNot a quote";
553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554 let result = rule.check(&ctx).unwrap();
555 assert!(result.is_empty(), "Blank line after blockquote ends is valid");
556 }
557
558 #[test]
559 fn test_blank_before_blockquote() {
560 let rule = MD028NoBlanksBlockquote;
561 let content = "Not a quote\n\n> Quote";
562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563 let result = rule.check(&ctx).unwrap();
564 assert!(result.is_empty(), "Blank line before blockquote starts is valid");
565 }
566
567 #[test]
568 fn test_preserve_trailing_newline() {
569 let rule = MD028NoBlanksBlockquote;
570 let content = "> Quote\n\n> More\n";
571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
572 let fixed = rule.fix(&ctx).unwrap();
573 assert!(fixed.ends_with('\n'));
574
575 let content_no_newline = "> Quote\n\n> More";
576 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
577 let fixed2 = rule.fix(&ctx2).unwrap();
578 assert!(!fixed2.ends_with('\n'));
579 }
580
581 #[test]
582 fn test_document_structure_extension() {
583 let rule = MD028NoBlanksBlockquote;
584 let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard, None);
585 let result = rule.check(&ctx).unwrap();
587 assert!(result.is_empty(), "Should not flag valid blockquote");
588
589 let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard, None);
591 assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
592 }
593
594 #[test]
595 fn test_deeply_nested_blank() {
596 let rule = MD028NoBlanksBlockquote;
597 let content = ">>> Deep nest\n\n>>> More deep";
598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599 let result = rule.check(&ctx).unwrap();
600 assert_eq!(result.len(), 1);
601
602 let fixed = rule.fix(&ctx).unwrap();
603 assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
604 }
605
606 #[test]
607 fn test_deeply_nested_with_marker() {
608 let rule = MD028NoBlanksBlockquote;
609 let content = ">>> Deep nest\n>>>\n>>> More deep";
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 >>> marker");
614 }
615
616 #[test]
617 fn test_complex_blockquote_structure() {
618 let rule = MD028NoBlanksBlockquote;
619 let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
622 let result = rule.check(&ctx).unwrap();
623 assert!(result.is_empty(), "Should not flag line with > marker");
624 }
625
626 #[test]
627 fn test_complex_with_blank() {
628 let rule = MD028NoBlanksBlockquote;
629 let content = "> Level 1\n> > Nested\n\n> Back to level 1";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633 let result = rule.check(&ctx).unwrap();
634 assert_eq!(
635 result.len(),
636 0,
637 "Blank between different nesting levels is not inside blockquote"
638 );
639 }
640
641 #[test]
648 fn test_gfm_alert_detection_note() {
649 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
650 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE] Additional text"));
651 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
652 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!note]")); assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!Note]")); }
655
656 #[test]
657 fn test_gfm_alert_detection_all_types() {
658 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
660 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!TIP]"));
661 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!IMPORTANT]"));
662 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!WARNING]"));
663 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!CAUTION]"));
664 }
665
666 #[test]
667 fn test_gfm_alert_detection_not_alert() {
668 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> Regular blockquote"));
670 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!INVALID]"));
671 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("> ")); }
677
678 #[test]
679 fn test_gfm_alerts_separated_by_blank_line() {
680 let rule = MD028NoBlanksBlockquote;
682 let content = "> [!TIP]\n> Here's a github tip\n\n> [!NOTE]\n> Here's a github note";
683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
684 let result = rule.check(&ctx).unwrap();
685 assert!(result.is_empty(), "Should not flag blank line between GFM alerts");
686 }
687
688 #[test]
689 fn test_gfm_alerts_all_five_types_separated() {
690 let rule = MD028NoBlanksBlockquote;
692 let content = r#"> [!NOTE]
693> Note content
694
695> [!TIP]
696> Tip content
697
698> [!IMPORTANT]
699> Important content
700
701> [!WARNING]
702> Warning content
703
704> [!CAUTION]
705> Caution content"#;
706 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
707 let result = rule.check(&ctx).unwrap();
708 assert!(
709 result.is_empty(),
710 "Should not flag blank lines between any GFM alert types"
711 );
712 }
713
714 #[test]
715 fn test_gfm_alert_with_multiple_lines() {
716 let rule = MD028NoBlanksBlockquote;
718 let content = r#"> [!WARNING]
719> This is a warning
720> with multiple lines
721> of content
722
723> [!NOTE]
724> This is a note"#;
725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726 let result = rule.check(&ctx).unwrap();
727 assert!(
728 result.is_empty(),
729 "Should not flag blank line between multi-line GFM alerts"
730 );
731 }
732
733 #[test]
734 fn test_gfm_alert_followed_by_regular_blockquote() {
735 let rule = MD028NoBlanksBlockquote;
737 let content = "> [!TIP]\n> A helpful tip\n\n> Regular blockquote";
738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739 let result = rule.check(&ctx).unwrap();
740 assert!(result.is_empty(), "Should not flag blank line after GFM alert");
741 }
742
743 #[test]
744 fn test_regular_blockquote_followed_by_gfm_alert() {
745 let rule = MD028NoBlanksBlockquote;
747 let content = "> Regular blockquote\n\n> [!NOTE]\n> Important note";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let result = rule.check(&ctx).unwrap();
750 assert!(result.is_empty(), "Should not flag blank line before GFM alert");
751 }
752
753 #[test]
754 fn test_regular_blockquotes_still_flagged() {
755 let rule = MD028NoBlanksBlockquote;
757 let content = "> First blockquote\n\n> Second blockquote";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759 let result = rule.check(&ctx).unwrap();
760 assert_eq!(
761 result.len(),
762 1,
763 "Should still flag blank line between regular blockquotes"
764 );
765 }
766
767 #[test]
768 fn test_gfm_alert_blank_line_within_same_alert() {
769 let rule = MD028NoBlanksBlockquote;
772 let content = "> [!NOTE]\n> First paragraph\n\n> Second paragraph of same note";
773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774 let result = rule.check(&ctx).unwrap();
775 assert!(
780 result.is_empty(),
781 "GFM alert status propagates to subsequent blockquote lines"
782 );
783 }
784
785 #[test]
786 fn test_gfm_alert_case_insensitive() {
787 let rule = MD028NoBlanksBlockquote;
788 let content = "> [!note]\n> lowercase\n\n> [!TIP]\n> uppercase\n\n> [!Warning]\n> mixed";
789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791 assert!(result.is_empty(), "GFM alert detection should be case insensitive");
792 }
793
794 #[test]
795 fn test_gfm_alert_with_nested_blockquote() {
796 let rule = MD028NoBlanksBlockquote;
798 let content = "> [!NOTE]\n> > Nested quote inside alert\n\n> [!TIP]\n> Tip";
799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800 let result = rule.check(&ctx).unwrap();
801 assert!(
802 result.is_empty(),
803 "Should not flag blank between alerts even with nested content"
804 );
805 }
806
807 #[test]
808 fn test_gfm_alert_indented() {
809 let rule = MD028NoBlanksBlockquote;
810 let content = " > [!NOTE]\n > Indented note\n\n > [!TIP]\n > Indented tip";
812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
813 let result = rule.check(&ctx).unwrap();
814 assert!(result.is_empty(), "Should not flag blank between indented GFM alerts");
815 }
816
817 #[test]
818 fn test_gfm_alert_mixed_with_regular_content() {
819 let rule = MD028NoBlanksBlockquote;
821 let content = r#"# Heading
822
823Some paragraph.
824
825> [!NOTE]
826> Important note
827
828More paragraph text.
829
830> [!WARNING]
831> Be careful!
832
833Final text."#;
834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
835 let result = rule.check(&ctx).unwrap();
836 assert!(
837 result.is_empty(),
838 "GFM alerts in mixed document should not trigger warnings"
839 );
840 }
841
842 #[test]
843 fn test_gfm_alert_fix_not_applied() {
844 let rule = MD028NoBlanksBlockquote;
846 let content = "> [!TIP]\n> Tip\n\n> [!NOTE]\n> Note";
847 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
848 let fixed = rule.fix(&ctx).unwrap();
849 assert_eq!(fixed, content, "Fix should not modify blank lines between GFM alerts");
850 }
851
852 #[test]
853 fn test_gfm_alert_multiple_blank_lines_between() {
854 let rule = MD028NoBlanksBlockquote;
856 let content = "> [!NOTE]\n> Note\n\n\n> [!TIP]\n> Tip";
857 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
858 let result = rule.check(&ctx).unwrap();
859 assert!(
860 result.is_empty(),
861 "Should not flag multiple blank lines between GFM alerts"
862 );
863 }
864}