1use crate::filtered_lines::FilteredLinesExt;
2use crate::utils::LineIndex;
3use crate::utils::range_utils::calculate_line_range;
4use std::collections::HashSet;
5use toml;
6
7use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
8use crate::rule_config_serde::RuleConfig;
9
10mod md012_config;
11use md012_config::MD012Config;
12
13#[derive(Debug, Clone, Default)]
18pub struct MD012NoMultipleBlanks {
19 config: MD012Config,
20}
21
22impl MD012NoMultipleBlanks {
23 pub fn new(maximum: usize) -> Self {
24 use crate::types::PositiveUsize;
25 Self {
26 config: MD012Config {
27 maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
28 },
29 }
30 }
31
32 pub const fn from_config_struct(config: MD012Config) -> Self {
33 Self { config }
34 }
35
36 fn generate_excess_warnings(
38 &self,
39 blank_start: usize,
40 blank_count: usize,
41 lines: &[&str],
42 lines_to_check: &HashSet<usize>,
43 line_index: &LineIndex,
44 ) -> Vec<LintWarning> {
45 let mut warnings = Vec::new();
46
47 let location = if blank_start == 0 {
48 "at start of file"
49 } else {
50 "between content"
51 };
52
53 for i in self.config.maximum.get()..blank_count {
54 let excess_line_num = blank_start + i;
55 if lines_to_check.contains(&excess_line_num) {
56 let excess_line = excess_line_num + 1;
57 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
58 let (start_line, start_col, end_line, end_col) = calculate_line_range(excess_line, excess_line_content);
59 warnings.push(LintWarning {
60 rule_name: Some(self.name().to_string()),
61 severity: Severity::Warning,
62 message: format!("Multiple consecutive blank lines {location}"),
63 line: start_line,
64 column: start_col,
65 end_line,
66 end_column: end_col,
67 fix: Some(Fix {
68 range: {
69 let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
70 let line_end = line_index
71 .get_line_start_byte(excess_line + 1)
72 .unwrap_or(line_start + 1);
73 line_start..line_end
74 },
75 replacement: String::new(),
76 }),
77 });
78 }
79 }
80
81 warnings
82 }
83}
84
85impl Rule for MD012NoMultipleBlanks {
86 fn name(&self) -> &'static str {
87 "MD012"
88 }
89
90 fn description(&self) -> &'static str {
91 "Multiple consecutive blank lines"
92 }
93
94 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
95 let content = ctx.content;
96
97 if content.is_empty() {
99 return Ok(Vec::new());
100 }
101
102 let lines: Vec<&str> = content.lines().collect();
105 let has_potential_blanks = lines
106 .windows(2)
107 .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
108
109 let ends_with_multiple_newlines = content.ends_with("\n\n");
112
113 if !has_potential_blanks && !ends_with_multiple_newlines {
114 return Ok(Vec::new());
115 }
116
117 let line_index = &ctx.line_index;
118
119 let mut warnings = Vec::new();
120
121 let mut blank_count = 0;
123 let mut blank_start = 0;
124 let mut last_line_num: Option<usize> = None;
125
126 let mut lines_to_check: HashSet<usize> = HashSet::new();
128
129 for filtered_line in ctx
135 .filtered_lines()
136 .skip_front_matter()
137 .skip_code_blocks()
138 .skip_quarto_divs()
139 .skip_math_blocks()
140 {
141 let line_num = filtered_line.line_num - 1; let line = filtered_line.content;
143
144 if let Some(last) = last_line_num
147 && line_num > last + 1
148 {
149 if blank_count > self.config.maximum.get() {
152 warnings.extend(self.generate_excess_warnings(
153 blank_start,
154 blank_count,
155 &lines,
156 &lines_to_check,
157 line_index,
158 ));
159 }
160 blank_count = 0;
161 lines_to_check.clear();
162 }
163 last_line_num = Some(line_num);
164
165 if line.trim().is_empty() {
166 if blank_count == 0 {
167 blank_start = line_num;
168 }
169 blank_count += 1;
170 if blank_count > self.config.maximum.get() {
172 lines_to_check.insert(line_num);
173 }
174 } else {
175 if blank_count > self.config.maximum.get() {
176 warnings.extend(self.generate_excess_warnings(
177 blank_start,
178 blank_count,
179 &lines,
180 &lines_to_check,
181 line_index,
182 ));
183 }
184 blank_count = 0;
185 lines_to_check.clear();
186 }
187 }
188
189 let last_line_is_blank = lines.last().is_some_and(|l| l.trim().is_empty());
196
197 if blank_count > 0 && last_line_is_blank {
201 let location = "at end of file";
202
203 let report_line = lines.len();
205
206 let fix_start = line_index
209 .get_line_start_byte(report_line - blank_count + 1)
210 .unwrap_or(0);
211 let fix_end = content.len();
212
213 warnings.push(LintWarning {
215 rule_name: Some(self.name().to_string()),
216 severity: Severity::Warning,
217 message: format!("Multiple consecutive blank lines {location}"),
218 line: report_line,
219 column: 1,
220 end_line: report_line,
221 end_column: 1,
222 fix: Some(Fix {
223 range: fix_start..fix_end,
224 replacement: String::new(),
228 }),
229 });
230 }
231
232 Ok(warnings)
233 }
234
235 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
236 let content = ctx.content;
237
238 let mut result = Vec::new();
239 let mut blank_count = 0;
240
241 let mut in_code_block = false;
242 let mut code_block_blanks = Vec::new();
243 let mut in_front_matter = false;
244
245 for filtered_line in ctx.filtered_lines() {
247 let line = filtered_line.content;
248
249 if filtered_line.line_info.in_front_matter {
251 if !in_front_matter {
252 let allowed_blanks = blank_count.min(self.config.maximum.get());
254 if allowed_blanks > 0 {
255 result.extend(vec![""; allowed_blanks]);
256 }
257 blank_count = 0;
258 in_front_matter = true;
259 }
260 result.push(line);
261 continue;
262 } else if in_front_matter {
263 in_front_matter = false;
265 }
266
267 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
269 if !in_code_block {
271 let allowed_blanks = blank_count.min(self.config.maximum.get());
272 if allowed_blanks > 0 {
273 result.extend(vec![""; allowed_blanks]);
274 }
275 blank_count = 0;
276 } else {
277 result.append(&mut code_block_blanks);
279 }
280 in_code_block = !in_code_block;
281 result.push(line);
282 continue;
283 }
284
285 if in_code_block {
286 if line.trim().is_empty() {
287 code_block_blanks.push(line);
288 } else {
289 result.append(&mut code_block_blanks);
290 result.push(line);
291 }
292 } else if line.trim().is_empty() {
293 blank_count += 1;
294 } else {
295 let allowed_blanks = blank_count.min(self.config.maximum.get());
297 if allowed_blanks > 0 {
298 result.extend(vec![""; allowed_blanks]);
299 }
300 blank_count = 0;
301 result.push(line);
302 }
303 }
304
305 let mut output = result.join("\n");
309 if content.ends_with('\n') {
310 output.push('\n');
311 }
312
313 Ok(output)
314 }
315
316 fn as_any(&self) -> &dyn std::any::Any {
317 self
318 }
319
320 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
321 ctx.content.is_empty() || !ctx.has_char('\n')
323 }
324
325 fn default_config_section(&self) -> Option<(String, toml::Value)> {
326 let default_config = MD012Config::default();
327 let json_value = serde_json::to_value(&default_config).ok()?;
328 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
329
330 if let toml::Value::Table(table) = toml_value {
331 if !table.is_empty() {
332 Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
333 } else {
334 None
335 }
336 } else {
337 None
338 }
339 }
340
341 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
342 where
343 Self: Sized,
344 {
345 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
346 Box::new(Self::from_config_struct(rule_config))
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use crate::lint_context::LintContext;
354
355 #[test]
356 fn test_single_blank_line_allowed() {
357 let rule = MD012NoMultipleBlanks::default();
358 let content = "Line 1\n\nLine 2\n\nLine 3";
359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
360 let result = rule.check(&ctx).unwrap();
361 assert!(result.is_empty());
362 }
363
364 #[test]
365 fn test_multiple_blank_lines_flagged() {
366 let rule = MD012NoMultipleBlanks::default();
367 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
369 let result = rule.check(&ctx).unwrap();
370 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
372 assert_eq!(result[1].line, 6);
373 assert_eq!(result[2].line, 7);
374 }
375
376 #[test]
377 fn test_custom_maximum() {
378 let rule = MD012NoMultipleBlanks::new(2);
379 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
381 let result = rule.check(&ctx).unwrap();
382 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
384 }
385
386 #[test]
387 fn test_fix_multiple_blank_lines() {
388 let rule = MD012NoMultipleBlanks::default();
389 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
391 let fixed = rule.fix(&ctx).unwrap();
392 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
393 }
394
395 #[test]
396 fn test_blank_lines_in_code_block() {
397 let rule = MD012NoMultipleBlanks::default();
398 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
400 let result = rule.check(&ctx).unwrap();
401 assert!(result.is_empty()); }
403
404 #[test]
405 fn test_fix_preserves_code_block_blanks() {
406 let rule = MD012NoMultipleBlanks::default();
407 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
409 let fixed = rule.fix(&ctx).unwrap();
410 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
411 }
412
413 #[test]
414 fn test_blank_lines_in_front_matter() {
415 let rule = MD012NoMultipleBlanks::default();
416 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
418 let result = rule.check(&ctx).unwrap();
419 assert!(result.is_empty()); }
421
422 #[test]
423 fn test_blank_lines_at_start() {
424 let rule = MD012NoMultipleBlanks::default();
425 let content = "\n\n\nContent";
426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
427 let result = rule.check(&ctx).unwrap();
428 assert_eq!(result.len(), 2);
429 assert!(result[0].message.contains("at start of file"));
430 }
431
432 #[test]
433 fn test_blank_lines_at_end() {
434 let rule = MD012NoMultipleBlanks::default();
435 let content = "Content\n\n\n";
436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
437 let result = rule.check(&ctx).unwrap();
438 assert_eq!(result.len(), 1);
439 assert!(result[0].message.contains("at end of file"));
440 }
441
442 #[test]
443 fn test_single_blank_at_eof_flagged() {
444 let rule = MD012NoMultipleBlanks::default();
446 let content = "Content\n\n";
447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
448 let result = rule.check(&ctx).unwrap();
449 assert_eq!(result.len(), 1);
450 assert!(result[0].message.contains("at end of file"));
451 }
452
453 #[test]
454 fn test_whitespace_only_lines() {
455 let rule = MD012NoMultipleBlanks::default();
456 let content = "Line 1\n \n\t\nLine 2";
457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
458 let result = rule.check(&ctx).unwrap();
459 assert_eq!(result.len(), 1); }
461
462 #[test]
463 fn test_indented_code_blocks() {
464 let rule = MD012NoMultipleBlanks::default();
466 let content = "Text\n\n code\n \n \n more code\n\nText";
467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
468 let result = rule.check(&ctx).unwrap();
469 assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
470 }
471
472 #[test]
473 fn test_blanks_in_indented_code_block() {
474 let content = " code line 1\n\n\n code line 2\n";
476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
477 let rule = MD012NoMultipleBlanks::default();
478 let warnings = rule.check(&ctx).unwrap();
479 assert!(warnings.is_empty(), "Should not flag blanks in indented code");
480 }
481
482 #[test]
483 fn test_blanks_in_indented_code_block_with_heading() {
484 let content = "# Heading\n\n code line 1\n\n\n code line 2\n\nMore text\n";
486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
487 let rule = MD012NoMultipleBlanks::default();
488 let warnings = rule.check(&ctx).unwrap();
489 assert!(
490 warnings.is_empty(),
491 "Should not flag blanks in indented code after heading"
492 );
493 }
494
495 #[test]
496 fn test_blanks_after_indented_code_block_flagged() {
497 let content = "# Heading\n\n code line\n\n\n\nMore text\n";
499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
500 let rule = MD012NoMultipleBlanks::default();
501 let warnings = rule.check(&ctx).unwrap();
502 assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
504 }
505
506 #[test]
507 fn test_fix_with_final_newline() {
508 let rule = MD012NoMultipleBlanks::default();
509 let content = "Line 1\n\n\nLine 2\n";
510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
511 let fixed = rule.fix(&ctx).unwrap();
512 assert_eq!(fixed, "Line 1\n\nLine 2\n");
513 assert!(fixed.ends_with('\n'));
514 }
515
516 #[test]
517 fn test_empty_content() {
518 let rule = MD012NoMultipleBlanks::default();
519 let content = "";
520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx).unwrap();
522 assert!(result.is_empty());
523 }
524
525 #[test]
526 fn test_nested_code_blocks() {
527 let rule = MD012NoMultipleBlanks::default();
528 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
530 let result = rule.check(&ctx).unwrap();
531 assert!(result.is_empty());
532 }
533
534 #[test]
535 fn test_unclosed_code_block() {
536 let rule = MD012NoMultipleBlanks::default();
537 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
539 let result = rule.check(&ctx).unwrap();
540 assert!(result.is_empty()); }
542
543 #[test]
544 fn test_mixed_fence_styles() {
545 let rule = MD012NoMultipleBlanks::default();
546 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
548 let result = rule.check(&ctx).unwrap();
549 assert!(result.is_empty()); }
551
552 #[test]
553 fn test_config_from_toml() {
554 let mut config = crate::config::Config::default();
555 let mut rule_config = crate::config::RuleConfig::default();
556 rule_config
557 .values
558 .insert("maximum".to_string(), toml::Value::Integer(3));
559 config.rules.insert("MD012".to_string(), rule_config);
560
561 let rule = MD012NoMultipleBlanks::from_config(&config);
562 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564 let result = rule.check(&ctx).unwrap();
565 assert!(result.is_empty()); }
567
568 #[test]
569 fn test_blank_lines_between_sections() {
570 let rule = MD012NoMultipleBlanks::default();
571 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573 let result = rule.check(&ctx).unwrap();
574 assert_eq!(result.len(), 1);
575 assert_eq!(result[0].line, 5);
576 }
577
578 #[test]
579 fn test_fix_preserves_indented_code() {
580 let rule = MD012NoMultipleBlanks::default();
581 let content = "Text\n\n\n code\n \n more code\n\n\nText";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583 let fixed = rule.fix(&ctx).unwrap();
584 assert_eq!(fixed, "Text\n\n code\n\n more code\n\nText");
586 }
587
588 #[test]
589 fn test_edge_case_only_blanks() {
590 let rule = MD012NoMultipleBlanks::default();
591 let content = "\n\n\n";
592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593 let result = rule.check(&ctx).unwrap();
594 assert_eq!(result.len(), 1);
596 assert!(result[0].message.contains("at end of file"));
597 }
598
599 #[test]
602 fn test_blanks_after_fenced_code_block_mid_document() {
603 let rule = MD012NoMultipleBlanks::default();
605 let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
607 let result = rule.check(&ctx).unwrap();
608 assert_eq!(result.len(), 1, "Should detect blanks after code block");
610 assert_eq!(result[0].line, 7, "Warning should be on line 7 (second blank)");
611 assert!(result[0].message.contains("between content"));
612 }
613
614 #[test]
615 fn test_blanks_after_code_block_at_eof() {
616 let rule = MD012NoMultipleBlanks::default();
618 let content = "# Heading\n\n```\ncode\n```\n\n\n";
619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620 let result = rule.check(&ctx).unwrap();
621 assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
623 assert!(result[0].message.contains("at end of file"));
624 }
625
626 #[test]
627 fn test_single_blank_after_code_block_allowed() {
628 let rule = MD012NoMultipleBlanks::default();
630 let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
632 let result = rule.check(&ctx).unwrap();
633 assert!(result.is_empty(), "Single blank after code block should be allowed");
634 }
635
636 #[test]
637 fn test_multiple_code_blocks_with_blanks() {
638 let rule = MD012NoMultipleBlanks::default();
640 let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642 let result = rule.check(&ctx).unwrap();
643 assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
645 }
646
647 #[test]
648 fn test_whitespace_only_lines_after_code_block_at_eof() {
649 let rule = MD012NoMultipleBlanks::default();
652 let content = "```\ncode\n```\n \n \n";
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654 let result = rule.check(&ctx).unwrap();
655 assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
656 assert!(result[0].message.contains("at end of file"));
657 }
658
659 #[test]
662 fn test_warning_fix_removes_single_trailing_blank() {
663 let rule = MD012NoMultipleBlanks::default();
665 let content = "hello foobar hello.\n\n";
666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
667 let warnings = rule.check(&ctx).unwrap();
668
669 assert_eq!(warnings.len(), 1);
670 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
671
672 let fix = warnings[0].fix.as_ref().unwrap();
673 assert_eq!(fix.replacement, "", "Replacement should be empty");
675
676 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
678 assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
679 }
680
681 #[test]
682 fn test_warning_fix_removes_multiple_trailing_blanks() {
683 let rule = MD012NoMultipleBlanks::default();
684 let content = "content\n\n\n\n";
685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
686 let warnings = rule.check(&ctx).unwrap();
687
688 assert_eq!(warnings.len(), 1);
689 assert!(warnings[0].fix.is_some());
690
691 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
692 assert_eq!(fixed, "content\n", "Should end with single newline");
693 }
694
695 #[test]
696 fn test_warning_fix_preserves_content_newline() {
697 let rule = MD012NoMultipleBlanks::default();
699 let content = "line1\nline2\n\n";
700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701 let warnings = rule.check(&ctx).unwrap();
702
703 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
704 assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
705 }
706
707 #[test]
708 fn test_warning_fix_mid_document_blanks() {
709 let rule = MD012NoMultipleBlanks::default();
711 let content = "# Heading\n\n\n\nParagraph\n";
713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714 let warnings = rule.check(&ctx).unwrap();
715
716 assert_eq!(warnings.len(), 2, "Should have 2 warnings for 2 extra blank lines");
718 assert!(warnings[0].fix.is_some());
719 assert!(warnings[1].fix.is_some());
720
721 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
722 assert_eq!(fixed, "# Heading\n\nParagraph\n", "Should reduce to single blank");
723 }
724
725 #[test]
728 fn test_blank_lines_in_quarto_callout() {
729 let rule = MD012NoMultipleBlanks::default();
731 let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
733 let result = rule.check(&ctx).unwrap();
734 assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
735 }
736
737 #[test]
738 fn test_blank_lines_in_quarto_div() {
739 let rule = MD012NoMultipleBlanks::default();
741 let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
743 let result = rule.check(&ctx).unwrap();
744 assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
745 }
746
747 #[test]
748 fn test_blank_lines_outside_quarto_div_flagged() {
749 let rule = MD012NoMultipleBlanks::default();
751 let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
753 let result = rule.check(&ctx).unwrap();
754 assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
755 }
756
757 #[test]
758 fn test_quarto_divs_ignored_in_standard_flavor() {
759 let rule = MD012NoMultipleBlanks::default();
761 let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763 let result = rule.check(&ctx).unwrap();
764 assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
766 }
767}