1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::calculate_indentation_width_default;
7use crate::utils::kramdown_utils::is_kramdown_block_attribute;
8use crate::utils::mkdocs_admonitions;
9use crate::utils::quarto_divs;
10use crate::utils::range_utils::calculate_line_range;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "kebab-case")]
16pub struct MD031Config {
17 #[serde(default = "default_list_items")]
19 pub list_items: bool,
20}
21
22impl Default for MD031Config {
23 fn default() -> Self {
24 Self {
25 list_items: default_list_items(),
26 }
27 }
28}
29
30fn default_list_items() -> bool {
31 true
32}
33
34impl RuleConfig for MD031Config {
35 const RULE_NAME: &'static str = "MD031";
36}
37
38#[derive(Clone, Default)]
40pub struct MD031BlanksAroundFences {
41 config: MD031Config,
42}
43
44impl MD031BlanksAroundFences {
45 pub fn new(list_items: bool) -> Self {
46 Self {
47 config: MD031Config { list_items },
48 }
49 }
50
51 pub fn from_config_struct(config: MD031Config) -> Self {
52 Self { config }
53 }
54
55 fn is_effectively_empty_line(line_idx: usize, lines: &[&str], ctx: &crate::lint_context::LintContext) -> bool {
58 let line = lines.get(line_idx).unwrap_or(&"");
59
60 if line.trim().is_empty() {
62 return true;
63 }
64
65 if let Some(line_info) = ctx.lines.get(line_idx)
67 && let Some(ref bq) = line_info.blockquote
68 {
69 return bq.content.trim().is_empty();
71 }
72
73 false
74 }
75
76 fn is_in_list(&self, line_index: usize, lines: &[&str]) -> bool {
78 for i in (0..=line_index).rev() {
80 let line = lines[i];
81 let trimmed = line.trim_start();
82
83 if trimmed.is_empty() {
85 return false;
86 }
87
88 if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
90 let mut chars = trimmed.chars().skip_while(|c| c.is_ascii_digit());
91 if let Some(next) = chars.next()
92 && (next == '.' || next == ')')
93 && chars.next() == Some(' ')
94 {
95 return true;
96 }
97 }
98
99 if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
101 return true;
102 }
103
104 let is_indented = calculate_indentation_width_default(line) >= 3;
106 if is_indented {
107 continue; }
109
110 return false;
113 }
114
115 false
116 }
117
118 fn should_require_blank_line(&self, line_index: usize, lines: &[&str]) -> bool {
120 if self.config.list_items {
121 true
123 } else {
124 !self.is_in_list(line_index, lines)
126 }
127 }
128
129 fn is_right_after_frontmatter(line_index: usize, ctx: &crate::lint_context::LintContext) -> bool {
131 line_index > 0
132 && ctx.lines.get(line_index - 1).is_some_and(|info| info.in_front_matter)
133 && ctx.lines.get(line_index).is_some_and(|info| !info.in_front_matter)
134 }
135
136 fn fenced_block_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
141 let lines = ctx.raw_lines();
142
143 ctx.code_block_details
144 .iter()
145 .filter(|d| d.is_fenced)
146 .map(|detail| {
147 let start_line = ctx
149 .line_offsets
150 .partition_point(|&off| off <= detail.start)
151 .saturating_sub(1);
152
153 let end_byte = if detail.end > 0 { detail.end - 1 } else { 0 };
155 let end_line = ctx
156 .line_offsets
157 .partition_point(|&off| off <= end_byte)
158 .saturating_sub(1);
159
160 let end_line_content = lines.get(end_line).unwrap_or(&"");
162 let trimmed = end_line_content.trim();
163 let content_after_bq = if trimmed.starts_with('>') {
164 trimmed.trim_start_matches(['>', ' ']).trim()
165 } else {
166 trimmed
167 };
168 let is_closing_fence = (content_after_bq.starts_with("```") || content_after_bq.starts_with("~~~"))
169 && content_after_bq
170 .chars()
171 .skip_while(|&c| c == '`' || c == '~')
172 .all(|c| c.is_whitespace());
173
174 if is_closing_fence {
175 (start_line, end_line)
176 } else {
177 (start_line, lines.len().saturating_sub(1))
178 }
179 })
180 .collect()
181 }
182}
183
184impl Rule for MD031BlanksAroundFences {
185 fn name(&self) -> &'static str {
186 "MD031"
187 }
188
189 fn description(&self) -> &'static str {
190 "Fenced code blocks should be surrounded by blank lines"
191 }
192
193 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
194 let line_index = &ctx.line_index;
195
196 let mut warnings = Vec::new();
197 let lines = ctx.raw_lines();
198 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
199 let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
200
201 let fenced_blocks = Self::fenced_block_line_ranges(ctx);
203
204 let is_quarto_div_marker =
206 |line: &str| -> bool { is_quarto && (quarto_divs::is_div_open(line) || quarto_divs::is_div_close(line)) };
207
208 for (opening_line, closing_line) in &fenced_blocks {
210 if ctx
212 .line_info(*opening_line + 1)
213 .is_some_and(|info| info.in_pymdown_block)
214 {
215 continue;
216 }
217
218 let prev_line_is_quarto_marker = *opening_line > 0 && is_quarto_div_marker(lines[*opening_line - 1]);
223 if *opening_line > 0
224 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
225 && !Self::is_right_after_frontmatter(*opening_line, ctx)
226 && !prev_line_is_quarto_marker
227 && self.should_require_blank_line(*opening_line, lines)
228 {
229 let (start_line, start_col, end_line, end_col) =
230 calculate_line_range(*opening_line + 1, lines[*opening_line]);
231
232 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
233 warnings.push(LintWarning {
234 rule_name: Some(self.name().to_string()),
235 line: start_line,
236 column: start_col,
237 end_line,
238 end_column: end_col,
239 message: "No blank line before fenced code block".to_string(),
240 severity: Severity::Warning,
241 fix: Some(Fix {
242 range: line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
243 replacement: format!("{bq_prefix}\n"),
244 }),
245 });
246 }
247
248 let next_line_is_quarto_marker =
253 *closing_line + 1 < lines.len() && is_quarto_div_marker(lines[*closing_line + 1]);
254 if *closing_line + 1 < lines.len()
255 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
256 && !is_kramdown_block_attribute(lines[*closing_line + 1])
257 && !next_line_is_quarto_marker
258 && self.should_require_blank_line(*closing_line, lines)
259 {
260 let (start_line, start_col, end_line, end_col) =
261 calculate_line_range(*closing_line + 1, lines[*closing_line]);
262
263 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
264 warnings.push(LintWarning {
265 rule_name: Some(self.name().to_string()),
266 line: start_line,
267 column: start_col,
268 end_line,
269 end_column: end_col,
270 message: "No blank line after fenced code block".to_string(),
271 severity: Severity::Warning,
272 fix: Some(Fix {
273 range: line_index.line_col_to_byte_range_with_length(*closing_line + 2, 1, 0),
274 replacement: format!("{bq_prefix}\n"),
275 }),
276 });
277 }
278 }
279
280 if is_mkdocs {
282 let mut in_admonition = false;
283 let mut admonition_indent = 0;
284 let mut i = 0;
285
286 while i < lines.len() {
287 let line = lines[i];
288
289 let in_fenced_block = fenced_blocks.iter().any(|(start, end)| i >= *start && i <= *end);
291 if in_fenced_block {
292 i += 1;
293 continue;
294 }
295
296 if ctx.line_info(i + 1).is_some_and(|info| info.in_pymdown_block) {
298 i += 1;
299 continue;
300 }
301
302 if mkdocs_admonitions::is_admonition_start(line) {
304 if i > 0
306 && !Self::is_effectively_empty_line(i - 1, lines, ctx)
307 && !Self::is_right_after_frontmatter(i, ctx)
308 && self.should_require_blank_line(i, lines)
309 {
310 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
311
312 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
313 warnings.push(LintWarning {
314 rule_name: Some(self.name().to_string()),
315 line: start_line,
316 column: start_col,
317 end_line,
318 end_column: end_col,
319 message: "No blank line before admonition block".to_string(),
320 severity: Severity::Warning,
321 fix: Some(Fix {
322 range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
323 replacement: format!("{bq_prefix}\n"),
324 }),
325 });
326 }
327
328 in_admonition = true;
329 admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
330 i += 1;
331 continue;
332 }
333
334 if in_admonition
336 && !line.trim().is_empty()
337 && !mkdocs_admonitions::is_admonition_content(line, admonition_indent)
338 {
339 in_admonition = false;
340
341 if i > 0
345 && !Self::is_effectively_empty_line(i - 1, lines, ctx)
346 && self.should_require_blank_line(i - 1, lines)
347 {
348 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
349
350 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
351 warnings.push(LintWarning {
352 rule_name: Some(self.name().to_string()),
353 line: start_line,
354 column: start_col,
355 end_line,
356 end_column: end_col,
357 message: "No blank line after admonition block".to_string(),
358 severity: Severity::Warning,
359 fix: Some(Fix {
360 range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
361 replacement: format!("{bq_prefix}\n"),
362 }),
363 });
364 }
365
366 admonition_indent = 0;
367 }
368
369 i += 1;
370 }
371 }
372
373 Ok(warnings)
374 }
375
376 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
377 if self.should_skip(ctx) {
378 return Ok(ctx.content.to_string());
379 }
380 let warnings = self.check(ctx)?;
381 if warnings.is_empty() {
382 return Ok(ctx.content.to_string());
383 }
384 let warnings =
385 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
386 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
387 .map_err(crate::rule::LintError::InvalidInput)
388 }
389
390 fn category(&self) -> RuleCategory {
392 RuleCategory::CodeBlock
393 }
394
395 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
397 if ctx.content.is_empty() {
398 return true;
399 }
400 let has_fences = ctx.likely_has_code() || ctx.has_char('~');
401 let has_mkdocs_admonitions = ctx.flavor == crate::config::MarkdownFlavor::MkDocs && ctx.content.contains("!!!");
402 !has_fences && !has_mkdocs_admonitions
403 }
404
405 fn as_any(&self) -> &dyn std::any::Any {
406 self
407 }
408
409 fn default_config_section(&self) -> Option<(String, toml::Value)> {
410 let default_config = MD031Config::default();
411 let json_value = serde_json::to_value(&default_config).ok()?;
412 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
413 if let toml::Value::Table(table) = toml_value {
414 if !table.is_empty() {
415 Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
416 } else {
417 None
418 }
419 } else {
420 None
421 }
422 }
423
424 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
425 where
426 Self: Sized,
427 {
428 let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
429 Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::lint_context::LintContext;
437
438 #[test]
439 fn test_basic_functionality() {
440 let rule = MD031BlanksAroundFences::default();
441
442 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
445 let warnings = rule.check(&ctx).unwrap();
446 assert!(
447 warnings.is_empty(),
448 "Expected no warnings for properly formatted code blocks"
449 );
450
451 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
454 let warnings = rule.check(&ctx).unwrap();
455 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
456 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
457 assert!(
458 warnings[0].message.contains("before"),
459 "Warning should be about blank line before"
460 );
461
462 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
465 let warnings = rule.check(&ctx).unwrap();
466 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
467 assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
468 assert!(
469 warnings[0].message.contains("after"),
470 "Warning should be about blank line after"
471 );
472
473 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
476 let warnings = rule.check(&ctx).unwrap();
477 assert_eq!(
478 warnings.len(),
479 2,
480 "Expected 2 warnings for missing blank lines before and after"
481 );
482 }
483
484 #[test]
485 fn test_nested_code_blocks() {
486 let rule = MD031BlanksAroundFences::default();
487
488 let content = r#"````markdown
490```
491content
492```
493````"#;
494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
495 let warnings = rule.check(&ctx).unwrap();
496 assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
497
498 let fixed = rule.fix(&ctx).unwrap();
500 assert_eq!(fixed, content, "Fix should not modify nested code blocks");
501 }
502
503 #[test]
504 fn test_nested_code_blocks_complex() {
505 let rule = MD031BlanksAroundFences::default();
506
507 let content = r#"# Documentation
509
510## Examples
511
512````markdown
513```python
514def hello():
515 print("Hello, world!")
516```
517
518```javascript
519console.log("Hello, world!");
520```
521````
522
523More text here."#;
524
525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526 let warnings = rule.check(&ctx).unwrap();
527 assert_eq!(
528 warnings.len(),
529 0,
530 "Should not flag any issues in properly formatted nested code blocks"
531 );
532
533 let content_5 = r#"`````markdown
535````python
536```bash
537echo "nested"
538```
539````
540`````"#;
541
542 let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
543 let warnings_5 = rule.check(&ctx_5).unwrap();
544 assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
545 }
546
547 #[test]
548 fn test_fix_preserves_trailing_newline() {
549 let rule = MD031BlanksAroundFences::default();
550
551 let content = "Some text\n```\ncode\n```\nMore text\n";
553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554 let fixed = rule.fix(&ctx).unwrap();
555
556 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
558 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
559 }
560
561 #[test]
562 fn test_fix_preserves_no_trailing_newline() {
563 let rule = MD031BlanksAroundFences::default();
564
565 let content = "Some text\n```\ncode\n```\nMore text";
567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
568 let fixed = rule.fix(&ctx).unwrap();
569
570 assert!(
572 !fixed.ends_with('\n'),
573 "Fix should not add trailing newline if original didn't have one"
574 );
575 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
576 }
577
578 #[test]
579 fn test_list_items_config_true() {
580 let rule = MD031BlanksAroundFences::new(true);
582
583 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585 let warnings = rule.check(&ctx).unwrap();
586
587 assert_eq!(warnings.len(), 2);
589 assert!(warnings[0].message.contains("before"));
590 assert!(warnings[1].message.contains("after"));
591 }
592
593 #[test]
594 fn test_list_items_config_false() {
595 let rule = MD031BlanksAroundFences::new(false);
597
598 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600 let warnings = rule.check(&ctx).unwrap();
601
602 assert_eq!(warnings.len(), 0);
604 }
605
606 #[test]
607 fn test_list_items_config_false_outside_list() {
608 let rule = MD031BlanksAroundFences::new(false);
610
611 let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613 let warnings = rule.check(&ctx).unwrap();
614
615 assert_eq!(warnings.len(), 2);
617 assert!(warnings[0].message.contains("before"));
618 assert!(warnings[1].message.contains("after"));
619 }
620
621 #[test]
622 fn test_default_config_section() {
623 let rule = MD031BlanksAroundFences::default();
624 let config_section = rule.default_config_section();
625
626 assert!(config_section.is_some());
627 let (name, value) = config_section.unwrap();
628 assert_eq!(name, "MD031");
629
630 if let toml::Value::Table(table) = value {
632 assert!(table.contains_key("list-items"));
633 assert_eq!(table["list-items"], toml::Value::Boolean(true));
634 } else {
635 panic!("Expected TOML table");
636 }
637 }
638
639 #[test]
640 fn test_fix_list_items_config_false() {
641 let rule = MD031BlanksAroundFences::new(false);
643
644 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646 let fixed = rule.fix(&ctx).unwrap();
647
648 assert_eq!(fixed, content);
650 }
651
652 #[test]
653 fn test_fix_list_items_config_true() {
654 let rule = MD031BlanksAroundFences::new(true);
656
657 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659 let fixed = rule.fix(&ctx).unwrap();
660
661 let expected = "1. First item\n\n ```python\n code()\n ```\n\n2. Second item";
663 assert_eq!(fixed, expected);
664 }
665
666 #[test]
667 fn test_no_warning_after_frontmatter() {
668 let rule = MD031BlanksAroundFences::default();
671
672 let content = "---\ntitle: Test\n---\n```\ncode\n```";
673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
674 let warnings = rule.check(&ctx).unwrap();
675
676 assert!(
678 warnings.is_empty(),
679 "Expected no warnings for code block after frontmatter, got: {warnings:?}"
680 );
681 }
682
683 #[test]
684 fn test_fix_does_not_add_blank_after_frontmatter() {
685 let rule = MD031BlanksAroundFences::default();
687
688 let content = "---\ntitle: Test\n---\n```\ncode\n```";
689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690 let fixed = rule.fix(&ctx).unwrap();
691
692 assert_eq!(fixed, content);
694 }
695
696 #[test]
697 fn test_frontmatter_with_blank_line_before_code() {
698 let rule = MD031BlanksAroundFences::default();
700
701 let content = "---\ntitle: Test\n---\n\n```\ncode\n```";
702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703 let warnings = rule.check(&ctx).unwrap();
704
705 assert!(warnings.is_empty());
706 }
707
708 #[test]
709 fn test_no_warning_for_admonition_after_frontmatter() {
710 let rule = MD031BlanksAroundFences::default();
712
713 let content = "---\ntitle: Test\n---\n!!! note\n This is a note";
714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
715 let warnings = rule.check(&ctx).unwrap();
716
717 assert!(
718 warnings.is_empty(),
719 "Expected no warnings for admonition after frontmatter, got: {warnings:?}"
720 );
721 }
722
723 #[test]
724 fn test_toml_frontmatter_before_code() {
725 let rule = MD031BlanksAroundFences::default();
727
728 let content = "+++\ntitle = \"Test\"\n+++\n```\ncode\n```";
729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730 let warnings = rule.check(&ctx).unwrap();
731
732 assert!(
733 warnings.is_empty(),
734 "Expected no warnings for code block after TOML frontmatter, got: {warnings:?}"
735 );
736 }
737
738 #[test]
739 fn test_fenced_code_in_list_with_4_space_indent_issue_276() {
740 let rule = MD031BlanksAroundFences::new(true);
744
745 let content =
747 "1. First item\n2. Second item with code:\n ```python\n print(\"Hello\")\n ```\n3. Third item";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let warnings = rule.check(&ctx).unwrap();
750
751 assert_eq!(
753 warnings.len(),
754 2,
755 "Should detect fenced code in list with 4-space indent, got: {warnings:?}"
756 );
757 assert!(warnings[0].message.contains("before"));
758 assert!(warnings[1].message.contains("after"));
759
760 let fixed = rule.fix(&ctx).unwrap();
762 let expected =
763 "1. First item\n2. Second item with code:\n\n ```python\n print(\"Hello\")\n ```\n\n3. Third item";
764 assert_eq!(
765 fixed, expected,
766 "Fix should add blank lines around list-indented fenced code"
767 );
768 }
769
770 #[test]
771 fn test_fenced_code_in_list_with_mixed_indentation() {
772 let rule = MD031BlanksAroundFences::new(true);
774
775 let content = r#"# Test
776
7773-space indent:
7781. First item
779 ```python
780 code
781 ```
7822. Second item
783
7844-space indent:
7851. First item
786 ```python
787 code
788 ```
7892. Second item"#;
790
791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
792 let warnings = rule.check(&ctx).unwrap();
793
794 assert_eq!(
796 warnings.len(),
797 4,
798 "Should detect all fenced code blocks regardless of indentation, got: {warnings:?}"
799 );
800 }
801
802 #[test]
803 fn test_fix_preserves_blockquote_prefix_before_fence() {
804 let rule = MD031BlanksAroundFences::default();
806
807 let content = "> Text before
808> ```
809> code
810> ```";
811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812 let fixed = rule.fix(&ctx).unwrap();
813
814 let expected = "> Text before
816>
817> ```
818> code
819> ```";
820 assert_eq!(
821 fixed, expected,
822 "Fix should insert '>' blank line, not plain blank line"
823 );
824 }
825
826 #[test]
827 fn test_fix_preserves_blockquote_prefix_after_fence() {
828 let rule = MD031BlanksAroundFences::default();
830
831 let content = "> ```
832> code
833> ```
834> Text after";
835 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
836 let fixed = rule.fix(&ctx).unwrap();
837
838 let expected = "> ```
840> code
841> ```
842>
843> Text after";
844 assert_eq!(
845 fixed, expected,
846 "Fix should insert '>' blank line after fence, not plain blank line"
847 );
848 }
849
850 #[test]
851 fn test_fix_preserves_nested_blockquote_prefix() {
852 let rule = MD031BlanksAroundFences::default();
854
855 let content = ">> Nested quote
856>> ```
857>> code
858>> ```
859>> More text";
860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
861 let fixed = rule.fix(&ctx).unwrap();
862
863 let expected = ">> Nested quote
865>>
866>> ```
867>> code
868>> ```
869>>
870>> More text";
871 assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
872 }
873
874 #[test]
875 fn test_fix_preserves_triple_nested_blockquote_prefix() {
876 let rule = MD031BlanksAroundFences::default();
878
879 let content = ">>> Triple nested
880>>> ```
881>>> code
882>>> ```
883>>> More text";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885 let fixed = rule.fix(&ctx).unwrap();
886
887 let expected = ">>> Triple nested
888>>>
889>>> ```
890>>> code
891>>> ```
892>>>
893>>> More text";
894 assert_eq!(
895 fixed, expected,
896 "Fix should preserve triple-nested blockquote prefix '>>>'"
897 );
898 }
899
900 #[test]
903 fn test_quarto_code_block_after_div_open() {
904 let rule = MD031BlanksAroundFences::default();
906 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
908 let warnings = rule.check(&ctx).unwrap();
909 assert!(
910 warnings.is_empty(),
911 "Should not require blank line after Quarto div opening: {warnings:?}"
912 );
913 }
914
915 #[test]
916 fn test_quarto_code_block_before_div_close() {
917 let rule = MD031BlanksAroundFences::default();
919 let content = "::: {.callout-note}\nSome text\n```python\ncode\n```\n:::";
920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
921 let warnings = rule.check(&ctx).unwrap();
922 assert!(
924 warnings.len() <= 1,
925 "Should not require blank line before Quarto div closing: {warnings:?}"
926 );
927 }
928
929 #[test]
930 fn test_quarto_code_block_outside_div_still_requires_blanks() {
931 let rule = MD031BlanksAroundFences::default();
933 let content = "Some text\n```python\ncode\n```\nMore text";
934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
935 let warnings = rule.check(&ctx).unwrap();
936 assert_eq!(
937 warnings.len(),
938 2,
939 "Should still require blank lines around code blocks outside divs"
940 );
941 }
942
943 #[test]
944 fn test_quarto_code_block_with_callout_note() {
945 let rule = MD031BlanksAroundFences::default();
947 let content = "::: {.callout-note}\n```r\n1 + 1\n```\n:::\n\nMore text";
948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
949 let warnings = rule.check(&ctx).unwrap();
950 assert!(
951 warnings.is_empty(),
952 "Callout note with code block should have no warnings: {warnings:?}"
953 );
954 }
955
956 #[test]
957 fn test_quarto_nested_divs_with_code() {
958 let rule = MD031BlanksAroundFences::default();
960 let content = "::: {.outer}\n::: {.inner}\n```python\ncode\n```\n:::\n:::\n";
961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
962 let warnings = rule.check(&ctx).unwrap();
963 assert!(
964 warnings.is_empty(),
965 "Nested divs with code blocks should have no warnings: {warnings:?}"
966 );
967 }
968
969 #[test]
970 fn test_quarto_div_markers_in_standard_flavor() {
971 let rule = MD031BlanksAroundFences::default();
973 let content = "::: {.callout-note}\n```python\ncode\n```\n:::\n";
974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975 let warnings = rule.check(&ctx).unwrap();
976 assert!(
979 !warnings.is_empty(),
980 "Standard flavor should require blanks around code blocks: {warnings:?}"
981 );
982 }
983
984 #[test]
985 fn test_quarto_fix_does_not_add_blanks_at_div_boundaries() {
986 let rule = MD031BlanksAroundFences::default();
988 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
990 let fixed = rule.fix(&ctx).unwrap();
991 assert_eq!(fixed, content, "Fix should not add blanks at Quarto div boundaries");
993 }
994
995 #[test]
996 fn test_quarto_code_block_with_content_before() {
997 let rule = MD031BlanksAroundFences::default();
999 let content = "::: {.callout-note}\nHere is some code:\n```python\ncode\n```\n:::";
1000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1001 let warnings = rule.check(&ctx).unwrap();
1002 assert_eq!(
1004 warnings.len(),
1005 1,
1006 "Should require blank before code block inside div: {warnings:?}"
1007 );
1008 assert!(warnings[0].message.contains("before"));
1009 }
1010
1011 #[test]
1012 fn test_quarto_code_block_with_content_after() {
1013 let rule = MD031BlanksAroundFences::default();
1015 let content = "::: {.callout-note}\n```python\ncode\n```\nMore content here.\n:::";
1016 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1017 let warnings = rule.check(&ctx).unwrap();
1018 assert_eq!(
1020 warnings.len(),
1021 1,
1022 "Should require blank after code block inside div: {warnings:?}"
1023 );
1024 assert!(warnings[0].message.contains("after"));
1025 }
1026}