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::pandoc;
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(char::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(char::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 fn colon_fence_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
185 ctx.colon_fence_ranges()
186 .iter()
187 .map(|&(start, end)| {
188 let start_line = ctx.line_offsets.partition_point(|&off| off <= start).saturating_sub(1);
189 let end_byte = if end > 0 { end - 1 } else { 0 };
190 let end_line = ctx
191 .line_offsets
192 .partition_point(|&off| off <= end_byte)
193 .saturating_sub(1);
194 (start_line, end_line)
195 })
196 .collect()
197 }
198}
199
200impl Rule for MD031BlanksAroundFences {
201 fn name(&self) -> &'static str {
202 "MD031"
203 }
204
205 fn description(&self) -> &'static str {
206 "Fenced code blocks should be surrounded by blank lines"
207 }
208
209 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
210 let line_index = &ctx.line_index;
211
212 let mut warnings = Vec::new();
213 let lines = ctx.raw_lines();
214 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
215 let is_pandoc = ctx.flavor.is_pandoc_compatible();
216
217 let fenced_blocks = Self::fenced_block_line_ranges(ctx);
219
220 let is_pandoc_div_marker =
222 |line: &str| -> bool { is_pandoc && (pandoc::is_div_open(line) || pandoc::is_div_close(line)) };
223
224 for (opening_line, closing_line) in &fenced_blocks {
226 if ctx
228 .line_info(*opening_line + 1)
229 .is_some_and(|info| info.in_pymdown_block)
230 {
231 continue;
232 }
233
234 let prev_line_is_pandoc_marker = *opening_line > 0 && is_pandoc_div_marker(lines[*opening_line - 1]);
239 if *opening_line > 0
240 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
241 && !Self::is_right_after_frontmatter(*opening_line, ctx)
242 && !prev_line_is_pandoc_marker
243 && self.should_require_blank_line(*opening_line, lines)
244 {
245 let (start_line, start_col, end_line, end_col) =
246 calculate_line_range(*opening_line + 1, lines[*opening_line]);
247
248 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
249 warnings.push(LintWarning {
250 rule_name: Some(self.name().to_string()),
251 line: start_line,
252 column: start_col,
253 end_line,
254 end_column: end_col,
255 message: "No blank line before fenced code block".to_string(),
256 severity: Severity::Warning,
257 fix: Some(Fix::new(
258 line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
259 format!("{bq_prefix}\n"),
260 )),
261 });
262 }
263
264 let next_line_is_pandoc_marker =
269 *closing_line + 1 < lines.len() && is_pandoc_div_marker(lines[*closing_line + 1]);
270 if *closing_line + 1 < lines.len()
271 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
272 && !is_kramdown_block_attribute(lines[*closing_line + 1])
273 && !next_line_is_pandoc_marker
274 && self.should_require_blank_line(*closing_line, lines)
275 {
276 let (start_line, start_col, end_line, end_col) =
277 calculate_line_range(*closing_line + 1, lines[*closing_line]);
278
279 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
280 warnings.push(LintWarning {
281 rule_name: Some(self.name().to_string()),
282 line: start_line,
283 column: start_col,
284 end_line,
285 end_column: end_col,
286 message: "No blank line after fenced code block".to_string(),
287 severity: Severity::Warning,
288 fix: Some(Fix::new(
289 line_index.line_col_to_byte_range_with_length(*closing_line + 2, 1, 0),
290 format!("{bq_prefix}\n"),
291 )),
292 });
293 }
294 }
295
296 if ctx.flavor.supports_colon_code_fences() {
298 let colon_blocks = Self::colon_fence_line_ranges(ctx);
299 for (opening_line, closing_line) in &colon_blocks {
300 if *opening_line > 0
302 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
303 && !Self::is_right_after_frontmatter(*opening_line, ctx)
304 && self.should_require_blank_line(*opening_line, lines)
305 {
306 let (start_line, start_col, end_line, end_col) =
307 calculate_line_range(*opening_line + 1, lines[*opening_line]);
308 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
309 warnings.push(LintWarning {
310 rule_name: Some(self.name().to_string()),
311 line: start_line,
312 column: start_col,
313 end_line,
314 end_column: end_col,
315 message: "No blank line before colon code fence".to_string(),
316 severity: Severity::Warning,
317 fix: Some(Fix::new(
318 line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
319 format!("{bq_prefix}\n"),
320 )),
321 });
322 }
323
324 if *closing_line + 1 < lines.len()
326 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
327 && self.should_require_blank_line(*closing_line, lines)
328 {
329 let (start_line, start_col, end_line, end_col) =
330 calculate_line_range(*closing_line + 1, lines[*closing_line]);
331 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
332 warnings.push(LintWarning {
333 rule_name: Some(self.name().to_string()),
334 line: start_line,
335 column: start_col,
336 end_line,
337 end_column: end_col,
338 message: "No blank line after colon code fence".to_string(),
339 severity: Severity::Warning,
340 fix: Some(Fix::new(
341 line_index.line_col_to_byte_range_with_length(*closing_line + 2, 1, 0),
342 format!("{bq_prefix}\n"),
343 )),
344 });
345 }
346 }
347 }
348
349 if is_mkdocs {
351 let mut in_admonition = false;
352 let mut admonition_indent = 0;
353 let mut i = 0;
354
355 while i < lines.len() {
356 let line = lines[i];
357
358 let in_fenced_block = fenced_blocks.iter().any(|(start, end)| i >= *start && i <= *end);
360 if in_fenced_block {
361 i += 1;
362 continue;
363 }
364
365 if ctx.line_info(i + 1).is_some_and(|info| info.in_pymdown_block) {
367 i += 1;
368 continue;
369 }
370
371 if mkdocs_admonitions::is_admonition_start(line) {
373 if i > 0
375 && !Self::is_effectively_empty_line(i - 1, lines, ctx)
376 && !Self::is_right_after_frontmatter(i, ctx)
377 && self.should_require_blank_line(i, lines)
378 {
379 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
380
381 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
382 warnings.push(LintWarning {
383 rule_name: Some(self.name().to_string()),
384 line: start_line,
385 column: start_col,
386 end_line,
387 end_column: end_col,
388 message: "No blank line before admonition block".to_string(),
389 severity: Severity::Warning,
390 fix: Some(Fix::new(
391 line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
392 format!("{bq_prefix}\n"),
393 )),
394 });
395 }
396
397 in_admonition = true;
398 admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
399 i += 1;
400 continue;
401 }
402
403 if in_admonition
405 && !line.trim().is_empty()
406 && !mkdocs_admonitions::is_admonition_content(line, admonition_indent)
407 {
408 in_admonition = false;
409
410 if i > 0
414 && !Self::is_effectively_empty_line(i - 1, lines, ctx)
415 && self.should_require_blank_line(i - 1, lines)
416 {
417 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
418
419 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
420 warnings.push(LintWarning {
421 rule_name: Some(self.name().to_string()),
422 line: start_line,
423 column: start_col,
424 end_line,
425 end_column: end_col,
426 message: "No blank line after admonition block".to_string(),
427 severity: Severity::Warning,
428 fix: Some(Fix::new(
429 line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
430 format!("{bq_prefix}\n"),
431 )),
432 });
433 }
434
435 admonition_indent = 0;
436 }
437
438 i += 1;
439 }
440 }
441
442 Ok(warnings)
443 }
444
445 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
446 if self.should_skip(ctx) {
447 return Ok(ctx.content.to_string());
448 }
449 let warnings = self.check(ctx)?;
450 if warnings.is_empty() {
451 return Ok(ctx.content.to_string());
452 }
453 let warnings =
454 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
455 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
456 .map_err(crate::rule::LintError::InvalidInput)
457 }
458
459 fn category(&self) -> RuleCategory {
461 RuleCategory::CodeBlock
462 }
463
464 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
466 if ctx.content.is_empty() {
467 return true;
468 }
469 let has_fences = ctx.likely_has_code() || ctx.has_char('~');
470 let has_mkdocs_admonitions = ctx.flavor == crate::config::MarkdownFlavor::MkDocs && ctx.content.contains("!!!");
471 let has_colon_fences = ctx.flavor.supports_colon_code_fences() && ctx.content.contains(":::");
472 !has_fences && !has_mkdocs_admonitions && !has_colon_fences
473 }
474
475 fn as_any(&self) -> &dyn std::any::Any {
476 self
477 }
478
479 fn default_config_section(&self) -> Option<(String, toml::Value)> {
480 let default_config = MD031Config::default();
481 let json_value = serde_json::to_value(&default_config).ok()?;
482 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
483 if let toml::Value::Table(table) = toml_value {
484 if !table.is_empty() {
485 Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
486 } else {
487 None
488 }
489 } else {
490 None
491 }
492 }
493
494 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
495 where
496 Self: Sized,
497 {
498 let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
499 Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use crate::lint_context::LintContext;
507
508 #[test]
509 fn test_basic_functionality() {
510 let rule = MD031BlanksAroundFences::default();
511
512 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
515 let warnings = rule.check(&ctx).unwrap();
516 assert!(
517 warnings.is_empty(),
518 "Expected no warnings for properly formatted code blocks"
519 );
520
521 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524 let warnings = rule.check(&ctx).unwrap();
525 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
526 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
527 assert!(
528 warnings[0].message.contains("before"),
529 "Warning should be about blank line before"
530 );
531
532 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
535 let warnings = rule.check(&ctx).unwrap();
536 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
537 assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
538 assert!(
539 warnings[0].message.contains("after"),
540 "Warning should be about blank line after"
541 );
542
543 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
546 let warnings = rule.check(&ctx).unwrap();
547 assert_eq!(
548 warnings.len(),
549 2,
550 "Expected 2 warnings for missing blank lines before and after"
551 );
552 }
553
554 #[test]
555 fn test_nested_code_blocks() {
556 let rule = MD031BlanksAroundFences::default();
557
558 let content = r#"````markdown
560```
561content
562```
563````"#;
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565 let warnings = rule.check(&ctx).unwrap();
566 assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
567
568 let fixed = rule.fix(&ctx).unwrap();
570 assert_eq!(fixed, content, "Fix should not modify nested code blocks");
571 }
572
573 #[test]
574 fn test_nested_code_blocks_complex() {
575 let rule = MD031BlanksAroundFences::default();
576
577 let content = r#"# Documentation
579
580## Examples
581
582````markdown
583```python
584def hello():
585 print("Hello, world!")
586```
587
588```javascript
589console.log("Hello, world!");
590```
591````
592
593More text here."#;
594
595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596 let warnings = rule.check(&ctx).unwrap();
597 assert_eq!(
598 warnings.len(),
599 0,
600 "Should not flag any issues in properly formatted nested code blocks"
601 );
602
603 let content_5 = r#"`````markdown
605````python
606```bash
607echo "nested"
608```
609````
610`````"#;
611
612 let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
613 let warnings_5 = rule.check(&ctx_5).unwrap();
614 assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
615 }
616
617 #[test]
618 fn test_fix_preserves_trailing_newline() {
619 let rule = MD031BlanksAroundFences::default();
620
621 let content = "Some text\n```\ncode\n```\nMore text\n";
623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624 let fixed = rule.fix(&ctx).unwrap();
625
626 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
628 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
629 }
630
631 #[test]
632 fn test_fix_preserves_no_trailing_newline() {
633 let rule = MD031BlanksAroundFences::default();
634
635 let content = "Some text\n```\ncode\n```\nMore text";
637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
638 let fixed = rule.fix(&ctx).unwrap();
639
640 assert!(
642 !fixed.ends_with('\n'),
643 "Fix should not add trailing newline if original didn't have one"
644 );
645 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
646 }
647
648 #[test]
649 fn test_list_items_config_true() {
650 let rule = MD031BlanksAroundFences::new(true);
652
653 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
655 let warnings = rule.check(&ctx).unwrap();
656
657 assert_eq!(warnings.len(), 2);
659 assert!(warnings[0].message.contains("before"));
660 assert!(warnings[1].message.contains("after"));
661 }
662
663 #[test]
664 fn test_list_items_config_false() {
665 let rule = MD031BlanksAroundFences::new(false);
667
668 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
670 let warnings = rule.check(&ctx).unwrap();
671
672 assert_eq!(warnings.len(), 0);
674 }
675
676 #[test]
677 fn test_list_items_config_false_outside_list() {
678 let rule = MD031BlanksAroundFences::new(false);
680
681 let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683 let warnings = rule.check(&ctx).unwrap();
684
685 assert_eq!(warnings.len(), 2);
687 assert!(warnings[0].message.contains("before"));
688 assert!(warnings[1].message.contains("after"));
689 }
690
691 #[test]
692 fn test_default_config_section() {
693 let rule = MD031BlanksAroundFences::default();
694 let config_section = rule.default_config_section();
695
696 assert!(config_section.is_some());
697 let (name, value) = config_section.unwrap();
698 assert_eq!(name, "MD031");
699
700 if let toml::Value::Table(table) = value {
702 assert!(table.contains_key("list-items"));
703 assert_eq!(table["list-items"], toml::Value::Boolean(true));
704 } else {
705 panic!("Expected TOML table");
706 }
707 }
708
709 #[test]
710 fn test_fix_list_items_config_false() {
711 let rule = MD031BlanksAroundFences::new(false);
713
714 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
716 let fixed = rule.fix(&ctx).unwrap();
717
718 assert_eq!(fixed, content);
720 }
721
722 #[test]
723 fn test_fix_list_items_config_true() {
724 let rule = MD031BlanksAroundFences::new(true);
726
727 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let fixed = rule.fix(&ctx).unwrap();
730
731 let expected = "1. First item\n\n ```python\n code()\n ```\n\n2. Second item";
733 assert_eq!(fixed, expected);
734 }
735
736 #[test]
737 fn test_no_warning_after_frontmatter() {
738 let rule = MD031BlanksAroundFences::default();
741
742 let content = "---\ntitle: Test\n---\n```\ncode\n```";
743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744 let warnings = rule.check(&ctx).unwrap();
745
746 assert!(
748 warnings.is_empty(),
749 "Expected no warnings for code block after frontmatter, got: {warnings:?}"
750 );
751 }
752
753 #[test]
754 fn test_fix_does_not_add_blank_after_frontmatter() {
755 let rule = MD031BlanksAroundFences::default();
757
758 let content = "---\ntitle: Test\n---\n```\ncode\n```";
759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let fixed = rule.fix(&ctx).unwrap();
761
762 assert_eq!(fixed, content);
764 }
765
766 #[test]
767 fn test_frontmatter_with_blank_line_before_code() {
768 let rule = MD031BlanksAroundFences::default();
770
771 let content = "---\ntitle: Test\n---\n\n```\ncode\n```";
772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773 let warnings = rule.check(&ctx).unwrap();
774
775 assert!(warnings.is_empty());
776 }
777
778 #[test]
779 fn test_no_warning_for_admonition_after_frontmatter() {
780 let rule = MD031BlanksAroundFences::default();
782
783 let content = "---\ntitle: Test\n---\n!!! note\n This is a note";
784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
785 let warnings = rule.check(&ctx).unwrap();
786
787 assert!(
788 warnings.is_empty(),
789 "Expected no warnings for admonition after frontmatter, got: {warnings:?}"
790 );
791 }
792
793 #[test]
794 fn test_toml_frontmatter_before_code() {
795 let rule = MD031BlanksAroundFences::default();
797
798 let content = "+++\ntitle = \"Test\"\n+++\n```\ncode\n```";
799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800 let warnings = rule.check(&ctx).unwrap();
801
802 assert!(
803 warnings.is_empty(),
804 "Expected no warnings for code block after TOML frontmatter, got: {warnings:?}"
805 );
806 }
807
808 #[test]
809 fn test_fenced_code_in_list_with_4_space_indent_issue_276() {
810 let rule = MD031BlanksAroundFences::new(true);
814
815 let content =
817 "1. First item\n2. Second item with code:\n ```python\n print(\"Hello\")\n ```\n3. Third item";
818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
819 let warnings = rule.check(&ctx).unwrap();
820
821 assert_eq!(
823 warnings.len(),
824 2,
825 "Should detect fenced code in list with 4-space indent, got: {warnings:?}"
826 );
827 assert!(warnings[0].message.contains("before"));
828 assert!(warnings[1].message.contains("after"));
829
830 let fixed = rule.fix(&ctx).unwrap();
832 let expected =
833 "1. First item\n2. Second item with code:\n\n ```python\n print(\"Hello\")\n ```\n\n3. Third item";
834 assert_eq!(
835 fixed, expected,
836 "Fix should add blank lines around list-indented fenced code"
837 );
838 }
839
840 #[test]
841 fn test_fenced_code_in_list_with_mixed_indentation() {
842 let rule = MD031BlanksAroundFences::new(true);
844
845 let content = r#"# Test
846
8473-space indent:
8481. First item
849 ```python
850 code
851 ```
8522. Second item
853
8544-space indent:
8551. First item
856 ```python
857 code
858 ```
8592. Second item"#;
860
861 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
862 let warnings = rule.check(&ctx).unwrap();
863
864 assert_eq!(
866 warnings.len(),
867 4,
868 "Should detect all fenced code blocks regardless of indentation, got: {warnings:?}"
869 );
870 }
871
872 #[test]
873 fn test_fix_preserves_blockquote_prefix_before_fence() {
874 let rule = MD031BlanksAroundFences::default();
876
877 let content = "> Text before
878> ```
879> code
880> ```";
881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
882 let fixed = rule.fix(&ctx).unwrap();
883
884 let expected = "> Text before
886>
887> ```
888> code
889> ```";
890 assert_eq!(
891 fixed, expected,
892 "Fix should insert '>' blank line, not plain blank line"
893 );
894 }
895
896 #[test]
897 fn test_fix_preserves_blockquote_prefix_after_fence() {
898 let rule = MD031BlanksAroundFences::default();
900
901 let content = "> ```
902> code
903> ```
904> Text after";
905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
906 let fixed = rule.fix(&ctx).unwrap();
907
908 let expected = "> ```
910> code
911> ```
912>
913> Text after";
914 assert_eq!(
915 fixed, expected,
916 "Fix should insert '>' blank line after fence, not plain blank line"
917 );
918 }
919
920 #[test]
921 fn test_fix_preserves_nested_blockquote_prefix() {
922 let rule = MD031BlanksAroundFences::default();
924
925 let content = ">> Nested quote
926>> ```
927>> code
928>> ```
929>> More text";
930 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
931 let fixed = rule.fix(&ctx).unwrap();
932
933 let expected = ">> Nested quote
935>>
936>> ```
937>> code
938>> ```
939>>
940>> More text";
941 assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
942 }
943
944 #[test]
945 fn test_fix_preserves_triple_nested_blockquote_prefix() {
946 let rule = MD031BlanksAroundFences::default();
948
949 let content = ">>> Triple nested
950>>> ```
951>>> code
952>>> ```
953>>> More text";
954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955 let fixed = rule.fix(&ctx).unwrap();
956
957 let expected = ">>> Triple nested
958>>>
959>>> ```
960>>> code
961>>> ```
962>>>
963>>> More text";
964 assert_eq!(
965 fixed, expected,
966 "Fix should preserve triple-nested blockquote prefix '>>>'"
967 );
968 }
969
970 #[test]
973 fn test_quarto_code_block_after_div_open() {
974 let rule = MD031BlanksAroundFences::default();
976 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
978 let warnings = rule.check(&ctx).unwrap();
979 assert!(
980 warnings.is_empty(),
981 "Should not require blank line after Quarto div opening: {warnings:?}"
982 );
983 }
984
985 #[test]
986 fn test_quarto_code_block_before_div_close() {
987 let rule = MD031BlanksAroundFences::default();
989 let content = "::: {.callout-note}\nSome text\n```python\ncode\n```\n:::";
990 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
991 let warnings = rule.check(&ctx).unwrap();
992 assert!(
994 warnings.len() <= 1,
995 "Should not require blank line before Quarto div closing: {warnings:?}"
996 );
997 }
998
999 #[test]
1000 fn test_quarto_code_block_outside_div_still_requires_blanks() {
1001 let rule = MD031BlanksAroundFences::default();
1003 let content = "Some text\n```python\ncode\n```\nMore text";
1004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1005 let warnings = rule.check(&ctx).unwrap();
1006 assert_eq!(
1007 warnings.len(),
1008 2,
1009 "Should still require blank lines around code blocks outside divs"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_quarto_code_block_with_callout_note() {
1015 let rule = MD031BlanksAroundFences::default();
1017 let content = "::: {.callout-note}\n```r\n1 + 1\n```\n:::\n\nMore text";
1018 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1019 let warnings = rule.check(&ctx).unwrap();
1020 assert!(
1021 warnings.is_empty(),
1022 "Callout note with code block should have no warnings: {warnings:?}"
1023 );
1024 }
1025
1026 #[test]
1027 fn test_quarto_nested_divs_with_code() {
1028 let rule = MD031BlanksAroundFences::default();
1030 let content = "::: {.outer}\n::: {.inner}\n```python\ncode\n```\n:::\n:::\n";
1031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1032 let warnings = rule.check(&ctx).unwrap();
1033 assert!(
1034 warnings.is_empty(),
1035 "Nested divs with code blocks should have no warnings: {warnings:?}"
1036 );
1037 }
1038
1039 #[test]
1040 fn test_quarto_div_markers_in_standard_flavor() {
1041 let rule = MD031BlanksAroundFences::default();
1043 let content = "::: {.callout-note}\n```python\ncode\n```\n:::\n";
1044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045 let warnings = rule.check(&ctx).unwrap();
1046 assert!(
1049 !warnings.is_empty(),
1050 "Standard flavor should require blanks around code blocks: {warnings:?}"
1051 );
1052 }
1053
1054 #[test]
1055 fn test_quarto_fix_does_not_add_blanks_at_div_boundaries() {
1056 let rule = MD031BlanksAroundFences::default();
1058 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1060 let fixed = rule.fix(&ctx).unwrap();
1061 assert_eq!(fixed, content, "Fix should not add blanks at Quarto div boundaries");
1063 }
1064
1065 #[test]
1066 fn test_quarto_code_block_with_content_before() {
1067 let rule = MD031BlanksAroundFences::default();
1069 let content = "::: {.callout-note}\nHere is some code:\n```python\ncode\n```\n:::";
1070 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1071 let warnings = rule.check(&ctx).unwrap();
1072 assert_eq!(
1074 warnings.len(),
1075 1,
1076 "Should require blank before code block inside div: {warnings:?}"
1077 );
1078 assert!(warnings[0].message.contains("before"));
1079 }
1080
1081 #[test]
1082 fn test_quarto_code_block_with_content_after() {
1083 let rule = MD031BlanksAroundFences::default();
1085 let content = "::: {.callout-note}\n```python\ncode\n```\nMore content here.\n:::";
1086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1087 let warnings = rule.check(&ctx).unwrap();
1088 assert_eq!(
1090 warnings.len(),
1091 1,
1092 "Should require blank after code block inside div: {warnings:?}"
1093 );
1094 assert!(warnings[0].message.contains("after"));
1095 }
1096
1097 #[test]
1098 fn test_pandoc_code_block_after_div_open() {
1099 let rule = MD031BlanksAroundFences::default();
1102 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1103 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1104 let warnings = rule.check(&ctx).unwrap();
1105 assert!(
1106 warnings.is_empty(),
1107 "MD031 should not require blank line after Pandoc div opening: {warnings:?}"
1108 );
1109 }
1110}