1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::element_cache::ElementCache;
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 pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16#[serde(rename_all = "kebab-case")]
17pub struct MD031Config {
18 #[serde(default = "default_list_items")]
20 pub list_items: bool,
21}
22
23impl Default for MD031Config {
24 fn default() -> Self {
25 Self {
26 list_items: default_list_items(),
27 }
28 }
29}
30
31fn default_list_items() -> bool {
32 true
33}
34
35impl RuleConfig for MD031Config {
36 const RULE_NAME: &'static str = "MD031";
37}
38
39#[derive(Clone, Default)]
41pub struct MD031BlanksAroundFences {
42 config: MD031Config,
43}
44
45impl MD031BlanksAroundFences {
46 pub fn new(list_items: bool) -> Self {
47 Self {
48 config: MD031Config { list_items },
49 }
50 }
51
52 pub fn from_config_struct(config: MD031Config) -> Self {
53 Self { config }
54 }
55
56 fn is_effectively_empty_line(line_idx: usize, lines: &[&str], ctx: &crate::lint_context::LintContext) -> bool {
59 let line = lines.get(line_idx).unwrap_or(&"");
60
61 if line.trim().is_empty() {
63 return true;
64 }
65
66 if let Some(line_info) = ctx.lines.get(line_idx)
68 && let Some(ref bq) = line_info.blockquote
69 {
70 return bq.content.trim().is_empty();
72 }
73
74 false
75 }
76
77 fn is_in_list(&self, line_index: usize, lines: &[&str]) -> bool {
79 for i in (0..=line_index).rev() {
81 let line = lines[i];
82 let trimmed = line.trim_start();
83
84 if trimmed.is_empty() {
86 return false;
87 }
88
89 if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
91 let mut chars = trimmed.chars().skip_while(|c| c.is_ascii_digit());
92 if let Some(next) = chars.next()
93 && (next == '.' || next == ')')
94 && chars.next() == Some(' ')
95 {
96 return true;
97 }
98 }
99
100 if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
102 return true;
103 }
104
105 let is_indented = ElementCache::calculate_indentation_width_default(line) >= 3;
107 if is_indented {
108 continue; }
110
111 return false;
114 }
115
116 false
117 }
118
119 fn should_require_blank_line(&self, line_index: usize, lines: &[&str]) -> bool {
121 if self.config.list_items {
122 true
124 } else {
125 !self.is_in_list(line_index, lines)
127 }
128 }
129
130 fn is_right_after_frontmatter(line_index: usize, ctx: &crate::lint_context::LintContext) -> bool {
132 line_index > 0
133 && ctx.lines.get(line_index - 1).is_some_and(|info| info.in_front_matter)
134 && ctx.lines.get(line_index).is_some_and(|info| !info.in_front_matter)
135 }
136
137 fn detect_fenced_code_blocks_pulldown(
142 content: &str,
143 line_offsets: &[usize],
144 lines: &[&str],
145 ) -> Vec<(usize, usize)> {
146 let mut fenced_blocks = Vec::new();
147 let options = Options::all();
148 let parser = Parser::new_ext(content, options).into_offset_iter();
149
150 let mut current_block_start: Option<usize> = None;
151
152 let byte_to_line = |byte_offset: usize| -> usize {
154 line_offsets
155 .iter()
156 .enumerate()
157 .rev()
158 .find(|&(_, &offset)| offset <= byte_offset)
159 .map(|(idx, _)| idx)
160 .unwrap_or(0)
161 };
162
163 for (event, range) in parser {
164 match event {
165 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) => {
166 let line_idx = byte_to_line(range.start);
167 current_block_start = Some(line_idx);
168 }
169 Event::End(TagEnd::CodeBlock) => {
170 if let Some(start_line) = current_block_start.take() {
171 let end_byte = if range.end > 0 { range.end - 1 } else { 0 };
175 let end_line = byte_to_line(end_byte);
176
177 let end_line_content = lines.get(end_line).unwrap_or(&"");
180 let trimmed = end_line_content.trim();
182 let content_after_bq = if trimmed.starts_with('>') {
183 trimmed.trim_start_matches(['>', ' ']).trim()
184 } else {
185 trimmed
186 };
187 let is_closing_fence = (content_after_bq.starts_with("```")
188 || content_after_bq.starts_with("~~~"))
189 && content_after_bq
190 .chars()
191 .skip_while(|&c| c == '`' || c == '~')
192 .all(|c| c.is_whitespace());
193
194 if is_closing_fence {
195 fenced_blocks.push((start_line, end_line));
196 } else {
197 fenced_blocks.push((start_line, lines.len().saturating_sub(1)));
200 }
201 }
202 }
203 _ => {}
204 }
205 }
206
207 if let Some(start_line) = current_block_start {
209 fenced_blocks.push((start_line, lines.len().saturating_sub(1)));
210 }
211
212 fenced_blocks
213 }
214}
215
216impl Rule for MD031BlanksAroundFences {
217 fn name(&self) -> &'static str {
218 "MD031"
219 }
220
221 fn description(&self) -> &'static str {
222 "Fenced code blocks should be surrounded by blank lines"
223 }
224
225 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
226 let content = ctx.content;
227 let line_index = &ctx.line_index;
228
229 let mut warnings = Vec::new();
230 let lines: Vec<&str> = content.lines().collect();
231 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
232 let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
233
234 let fenced_blocks = Self::detect_fenced_code_blocks_pulldown(content, &ctx.line_offsets, &lines);
236
237 let is_quarto_div_marker =
239 |line: &str| -> bool { is_quarto && (quarto_divs::is_div_open(line) || quarto_divs::is_div_close(line)) };
240
241 for (opening_line, closing_line) in &fenced_blocks {
243 let prev_line_is_quarto_marker = *opening_line > 0 && is_quarto_div_marker(lines[*opening_line - 1]);
248 if *opening_line > 0
249 && !Self::is_effectively_empty_line(*opening_line - 1, &lines, ctx)
250 && !Self::is_right_after_frontmatter(*opening_line, ctx)
251 && !prev_line_is_quarto_marker
252 && self.should_require_blank_line(*opening_line, &lines)
253 {
254 let (start_line, start_col, end_line, end_col) =
255 calculate_line_range(*opening_line + 1, lines[*opening_line]);
256
257 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
258 warnings.push(LintWarning {
259 rule_name: Some(self.name().to_string()),
260 line: start_line,
261 column: start_col,
262 end_line,
263 end_column: end_col,
264 message: "No blank line before fenced code block".to_string(),
265 severity: Severity::Warning,
266 fix: Some(Fix {
267 range: line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
268 replacement: format!("{bq_prefix}\n"),
269 }),
270 });
271 }
272
273 let next_line_is_quarto_marker =
278 *closing_line + 1 < lines.len() && is_quarto_div_marker(lines[*closing_line + 1]);
279 if *closing_line + 1 < lines.len()
280 && !Self::is_effectively_empty_line(*closing_line + 1, &lines, ctx)
281 && !is_kramdown_block_attribute(lines[*closing_line + 1])
282 && !next_line_is_quarto_marker
283 && self.should_require_blank_line(*closing_line, &lines)
284 {
285 let (start_line, start_col, end_line, end_col) =
286 calculate_line_range(*closing_line + 1, lines[*closing_line]);
287
288 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
289 warnings.push(LintWarning {
290 rule_name: Some(self.name().to_string()),
291 line: start_line,
292 column: start_col,
293 end_line,
294 end_column: end_col,
295 message: "No blank line after fenced code block".to_string(),
296 severity: Severity::Warning,
297 fix: Some(Fix {
298 range: line_index.line_col_to_byte_range_with_length(
299 *closing_line + 1,
300 lines[*closing_line].len() + 1,
301 0,
302 ),
303 replacement: format!("{bq_prefix}\n"),
304 }),
305 });
306 }
307 }
308
309 if is_mkdocs {
311 let mut in_admonition = false;
312 let mut admonition_indent = 0;
313 let mut i = 0;
314
315 while i < lines.len() {
316 let line = lines[i];
317
318 let in_fenced_block = fenced_blocks.iter().any(|(start, end)| i >= *start && i <= *end);
320 if in_fenced_block {
321 i += 1;
322 continue;
323 }
324
325 if mkdocs_admonitions::is_admonition_start(line) {
327 if i > 0
329 && !Self::is_effectively_empty_line(i - 1, &lines, ctx)
330 && !Self::is_right_after_frontmatter(i, ctx)
331 && self.should_require_blank_line(i, &lines)
332 {
333 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
334
335 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
336 warnings.push(LintWarning {
337 rule_name: Some(self.name().to_string()),
338 line: start_line,
339 column: start_col,
340 end_line,
341 end_column: end_col,
342 message: "No blank line before admonition block".to_string(),
343 severity: Severity::Warning,
344 fix: Some(Fix {
345 range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
346 replacement: format!("{bq_prefix}\n"),
347 }),
348 });
349 }
350
351 in_admonition = true;
352 admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
353 i += 1;
354 continue;
355 }
356
357 if in_admonition
359 && !line.trim().is_empty()
360 && !mkdocs_admonitions::is_admonition_content(line, admonition_indent)
361 {
362 in_admonition = false;
363
364 if i > 0
368 && !Self::is_effectively_empty_line(i - 1, &lines, ctx)
369 && self.should_require_blank_line(i - 1, &lines)
370 {
371 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
372
373 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
374 warnings.push(LintWarning {
375 rule_name: Some(self.name().to_string()),
376 line: start_line,
377 column: start_col,
378 end_line,
379 end_column: end_col,
380 message: "No blank line after admonition block".to_string(),
381 severity: Severity::Warning,
382 fix: Some(Fix {
383 range: line_index.line_col_to_byte_range_with_length(i, 0, 0),
384 replacement: format!("{bq_prefix}\n"),
385 }),
386 });
387 }
388
389 admonition_indent = 0;
390 }
391
392 i += 1;
393 }
394 }
395
396 Ok(warnings)
397 }
398
399 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
400 let content = ctx.content;
401
402 let had_trailing_newline = content.ends_with('\n');
404
405 let lines: Vec<&str> = content.lines().collect();
406 let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
407
408 let is_quarto_div_marker =
410 |line: &str| -> bool { is_quarto && (quarto_divs::is_div_open(line) || quarto_divs::is_div_close(line)) };
411
412 let fenced_blocks = Self::detect_fenced_code_blocks_pulldown(content, &ctx.line_offsets, &lines);
414
415 let mut needs_blank_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
417 let mut needs_blank_after: std::collections::HashSet<usize> = std::collections::HashSet::new();
418
419 for (opening_line, closing_line) in &fenced_blocks {
420 let prev_line_is_quarto_marker = *opening_line > 0 && is_quarto_div_marker(lines[*opening_line - 1]);
424 if *opening_line > 0
425 && !Self::is_effectively_empty_line(*opening_line - 1, &lines, ctx)
426 && !Self::is_right_after_frontmatter(*opening_line, ctx)
427 && !prev_line_is_quarto_marker
428 && self.should_require_blank_line(*opening_line, &lines)
429 {
430 needs_blank_before.insert(*opening_line);
431 }
432
433 let next_line_is_quarto_marker =
437 *closing_line + 1 < lines.len() && is_quarto_div_marker(lines[*closing_line + 1]);
438 if *closing_line + 1 < lines.len()
439 && !Self::is_effectively_empty_line(*closing_line + 1, &lines, ctx)
440 && !is_kramdown_block_attribute(lines[*closing_line + 1])
441 && !next_line_is_quarto_marker
442 && self.should_require_blank_line(*closing_line, &lines)
443 {
444 needs_blank_after.insert(*closing_line);
445 }
446 }
447
448 let mut result = Vec::new();
450 for (i, line) in lines.iter().enumerate() {
451 if needs_blank_before.contains(&i) {
453 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
454 result.push(bq_prefix);
455 }
456
457 result.push((*line).to_string());
458
459 if needs_blank_after.contains(&i) {
461 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
462 result.push(bq_prefix);
463 }
464 }
465
466 let fixed = result.join("\n");
467
468 let final_result = if had_trailing_newline && !fixed.ends_with('\n') {
470 format!("{fixed}\n")
471 } else {
472 fixed
473 };
474
475 Ok(final_result)
476 }
477
478 fn category(&self) -> RuleCategory {
480 RuleCategory::CodeBlock
481 }
482
483 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
485 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
487 }
488
489 fn as_any(&self) -> &dyn std::any::Any {
490 self
491 }
492
493 fn default_config_section(&self) -> Option<(String, toml::Value)> {
494 let default_config = MD031Config::default();
495 let json_value = serde_json::to_value(&default_config).ok()?;
496 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
497 if let toml::Value::Table(table) = toml_value {
498 if !table.is_empty() {
499 Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
500 } else {
501 None
502 }
503 } else {
504 None
505 }
506 }
507
508 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
509 where
510 Self: Sized,
511 {
512 let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
513 Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use crate::lint_context::LintContext;
521
522 #[test]
523 fn test_basic_functionality() {
524 let rule = MD031BlanksAroundFences::default();
525
526 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
529 let warnings = rule.check(&ctx).unwrap();
530 assert!(
531 warnings.is_empty(),
532 "Expected no warnings for properly formatted code blocks"
533 );
534
535 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
538 let warnings = rule.check(&ctx).unwrap();
539 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
540 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
541 assert!(
542 warnings[0].message.contains("before"),
543 "Warning should be about blank line before"
544 );
545
546 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
549 let warnings = rule.check(&ctx).unwrap();
550 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
551 assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
552 assert!(
553 warnings[0].message.contains("after"),
554 "Warning should be about blank line after"
555 );
556
557 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
560 let warnings = rule.check(&ctx).unwrap();
561 assert_eq!(
562 warnings.len(),
563 2,
564 "Expected 2 warnings for missing blank lines before and after"
565 );
566 }
567
568 #[test]
569 fn test_nested_code_blocks() {
570 let rule = MD031BlanksAroundFences::default();
571
572 let content = r#"````markdown
574```
575content
576```
577````"#;
578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
579 let warnings = rule.check(&ctx).unwrap();
580 assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
581
582 let fixed = rule.fix(&ctx).unwrap();
584 assert_eq!(fixed, content, "Fix should not modify nested code blocks");
585 }
586
587 #[test]
588 fn test_nested_code_blocks_complex() {
589 let rule = MD031BlanksAroundFences::default();
590
591 let content = r#"# Documentation
593
594## Examples
595
596````markdown
597```python
598def hello():
599 print("Hello, world!")
600```
601
602```javascript
603console.log("Hello, world!");
604```
605````
606
607More text here."#;
608
609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
610 let warnings = rule.check(&ctx).unwrap();
611 assert_eq!(
612 warnings.len(),
613 0,
614 "Should not flag any issues in properly formatted nested code blocks"
615 );
616
617 let content_5 = r#"`````markdown
619````python
620```bash
621echo "nested"
622```
623````
624`````"#;
625
626 let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
627 let warnings_5 = rule.check(&ctx_5).unwrap();
628 assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
629 }
630
631 #[test]
632 fn test_fix_preserves_trailing_newline() {
633 let rule = MD031BlanksAroundFences::default();
634
635 let content = "Some text\n```\ncode\n```\nMore text\n";
637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
638 let fixed = rule.fix(&ctx).unwrap();
639
640 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
642 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
643 }
644
645 #[test]
646 fn test_fix_preserves_no_trailing_newline() {
647 let rule = MD031BlanksAroundFences::default();
648
649 let content = "Some text\n```\ncode\n```\nMore text";
651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652 let fixed = rule.fix(&ctx).unwrap();
653
654 assert!(
656 !fixed.ends_with('\n'),
657 "Fix should not add trailing newline if original didn't have one"
658 );
659 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
660 }
661
662 #[test]
663 fn test_list_items_config_true() {
664 let rule = MD031BlanksAroundFences::new(true);
666
667 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669 let warnings = rule.check(&ctx).unwrap();
670
671 assert_eq!(warnings.len(), 2);
673 assert!(warnings[0].message.contains("before"));
674 assert!(warnings[1].message.contains("after"));
675 }
676
677 #[test]
678 fn test_list_items_config_false() {
679 let rule = MD031BlanksAroundFences::new(false);
681
682 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
684 let warnings = rule.check(&ctx).unwrap();
685
686 assert_eq!(warnings.len(), 0);
688 }
689
690 #[test]
691 fn test_list_items_config_false_outside_list() {
692 let rule = MD031BlanksAroundFences::new(false);
694
695 let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
697 let warnings = rule.check(&ctx).unwrap();
698
699 assert_eq!(warnings.len(), 2);
701 assert!(warnings[0].message.contains("before"));
702 assert!(warnings[1].message.contains("after"));
703 }
704
705 #[test]
706 fn test_default_config_section() {
707 let rule = MD031BlanksAroundFences::default();
708 let config_section = rule.default_config_section();
709
710 assert!(config_section.is_some());
711 let (name, value) = config_section.unwrap();
712 assert_eq!(name, "MD031");
713
714 if let toml::Value::Table(table) = value {
716 assert!(table.contains_key("list-items"));
717 assert_eq!(table["list-items"], toml::Value::Boolean(true));
718 } else {
719 panic!("Expected TOML table");
720 }
721 }
722
723 #[test]
724 fn test_fix_list_items_config_false() {
725 let rule = MD031BlanksAroundFences::new(false);
727
728 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730 let fixed = rule.fix(&ctx).unwrap();
731
732 assert_eq!(fixed, content);
734 }
735
736 #[test]
737 fn test_fix_list_items_config_true() {
738 let rule = MD031BlanksAroundFences::new(true);
740
741 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
743 let fixed = rule.fix(&ctx).unwrap();
744
745 let expected = "1. First item\n\n ```python\n code()\n ```\n\n2. Second item";
747 assert_eq!(fixed, expected);
748 }
749
750 #[test]
751 fn test_no_warning_after_frontmatter() {
752 let rule = MD031BlanksAroundFences::default();
755
756 let content = "---\ntitle: Test\n---\n```\ncode\n```";
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758 let warnings = rule.check(&ctx).unwrap();
759
760 assert!(
762 warnings.is_empty(),
763 "Expected no warnings for code block after frontmatter, got: {warnings:?}"
764 );
765 }
766
767 #[test]
768 fn test_fix_does_not_add_blank_after_frontmatter() {
769 let rule = MD031BlanksAroundFences::default();
771
772 let content = "---\ntitle: Test\n---\n```\ncode\n```";
773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774 let fixed = rule.fix(&ctx).unwrap();
775
776 assert_eq!(fixed, content);
778 }
779
780 #[test]
781 fn test_frontmatter_with_blank_line_before_code() {
782 let rule = MD031BlanksAroundFences::default();
784
785 let content = "---\ntitle: Test\n---\n\n```\ncode\n```";
786 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
787 let warnings = rule.check(&ctx).unwrap();
788
789 assert!(warnings.is_empty());
790 }
791
792 #[test]
793 fn test_no_warning_for_admonition_after_frontmatter() {
794 let rule = MD031BlanksAroundFences::default();
796
797 let content = "---\ntitle: Test\n---\n!!! note\n This is a note";
798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
799 let warnings = rule.check(&ctx).unwrap();
800
801 assert!(
802 warnings.is_empty(),
803 "Expected no warnings for admonition after frontmatter, got: {warnings:?}"
804 );
805 }
806
807 #[test]
808 fn test_toml_frontmatter_before_code() {
809 let rule = MD031BlanksAroundFences::default();
811
812 let content = "+++\ntitle = \"Test\"\n+++\n```\ncode\n```";
813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
814 let warnings = rule.check(&ctx).unwrap();
815
816 assert!(
817 warnings.is_empty(),
818 "Expected no warnings for code block after TOML frontmatter, got: {warnings:?}"
819 );
820 }
821
822 #[test]
823 fn test_fenced_code_in_list_with_4_space_indent_issue_276() {
824 let rule = MD031BlanksAroundFences::new(true);
828
829 let content =
831 "1. First item\n2. Second item with code:\n ```python\n print(\"Hello\")\n ```\n3. Third item";
832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833 let warnings = rule.check(&ctx).unwrap();
834
835 assert_eq!(
837 warnings.len(),
838 2,
839 "Should detect fenced code in list with 4-space indent, got: {warnings:?}"
840 );
841 assert!(warnings[0].message.contains("before"));
842 assert!(warnings[1].message.contains("after"));
843
844 let fixed = rule.fix(&ctx).unwrap();
846 let expected =
847 "1. First item\n2. Second item with code:\n\n ```python\n print(\"Hello\")\n ```\n\n3. Third item";
848 assert_eq!(
849 fixed, expected,
850 "Fix should add blank lines around list-indented fenced code"
851 );
852 }
853
854 #[test]
855 fn test_fenced_code_in_list_with_mixed_indentation() {
856 let rule = MD031BlanksAroundFences::new(true);
858
859 let content = r#"# Test
860
8613-space indent:
8621. First item
863 ```python
864 code
865 ```
8662. Second item
867
8684-space indent:
8691. First item
870 ```python
871 code
872 ```
8732. Second item"#;
874
875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876 let warnings = rule.check(&ctx).unwrap();
877
878 assert_eq!(
880 warnings.len(),
881 4,
882 "Should detect all fenced code blocks regardless of indentation, got: {warnings:?}"
883 );
884 }
885
886 #[test]
887 fn test_fix_preserves_blockquote_prefix_before_fence() {
888 let rule = MD031BlanksAroundFences::default();
890
891 let content = "> Text before
892> ```
893> code
894> ```";
895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896 let fixed = rule.fix(&ctx).unwrap();
897
898 let expected = "> Text before
900>
901> ```
902> code
903> ```";
904 assert_eq!(
905 fixed, expected,
906 "Fix should insert '>' blank line, not plain blank line"
907 );
908 }
909
910 #[test]
911 fn test_fix_preserves_blockquote_prefix_after_fence() {
912 let rule = MD031BlanksAroundFences::default();
914
915 let content = "> ```
916> code
917> ```
918> Text after";
919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
920 let fixed = rule.fix(&ctx).unwrap();
921
922 let expected = "> ```
924> code
925> ```
926>
927> Text after";
928 assert_eq!(
929 fixed, expected,
930 "Fix should insert '>' blank line after fence, not plain blank line"
931 );
932 }
933
934 #[test]
935 fn test_fix_preserves_nested_blockquote_prefix() {
936 let rule = MD031BlanksAroundFences::default();
938
939 let content = ">> Nested quote
940>> ```
941>> code
942>> ```
943>> More text";
944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
945 let fixed = rule.fix(&ctx).unwrap();
946
947 let expected = ">> Nested quote
949>>
950>> ```
951>> code
952>> ```
953>>
954>> More text";
955 assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
956 }
957
958 #[test]
959 fn test_fix_preserves_triple_nested_blockquote_prefix() {
960 let rule = MD031BlanksAroundFences::default();
962
963 let content = ">>> Triple nested
964>>> ```
965>>> code
966>>> ```
967>>> More text";
968 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
969 let fixed = rule.fix(&ctx).unwrap();
970
971 let expected = ">>> Triple nested
972>>>
973>>> ```
974>>> code
975>>> ```
976>>>
977>>> More text";
978 assert_eq!(
979 fixed, expected,
980 "Fix should preserve triple-nested blockquote prefix '>>>'"
981 );
982 }
983
984 #[test]
987 fn test_quarto_code_block_after_div_open() {
988 let rule = MD031BlanksAroundFences::default();
990 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
991 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
992 let warnings = rule.check(&ctx).unwrap();
993 assert!(
994 warnings.is_empty(),
995 "Should not require blank line after Quarto div opening: {warnings:?}"
996 );
997 }
998
999 #[test]
1000 fn test_quarto_code_block_before_div_close() {
1001 let rule = MD031BlanksAroundFences::default();
1003 let content = "::: {.callout-note}\nSome text\n```python\ncode\n```\n:::";
1004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1005 let warnings = rule.check(&ctx).unwrap();
1006 assert!(
1008 warnings.len() <= 1,
1009 "Should not require blank line before Quarto div closing: {warnings:?}"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_quarto_code_block_outside_div_still_requires_blanks() {
1015 let rule = MD031BlanksAroundFences::default();
1017 let content = "Some text\n```python\ncode\n```\nMore text";
1018 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1019 let warnings = rule.check(&ctx).unwrap();
1020 assert_eq!(
1021 warnings.len(),
1022 2,
1023 "Should still require blank lines around code blocks outside divs"
1024 );
1025 }
1026
1027 #[test]
1028 fn test_quarto_code_block_with_callout_note() {
1029 let rule = MD031BlanksAroundFences::default();
1031 let content = "::: {.callout-note}\n```r\n1 + 1\n```\n:::\n\nMore text";
1032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1033 let warnings = rule.check(&ctx).unwrap();
1034 assert!(
1035 warnings.is_empty(),
1036 "Callout note with code block should have no warnings: {warnings:?}"
1037 );
1038 }
1039
1040 #[test]
1041 fn test_quarto_nested_divs_with_code() {
1042 let rule = MD031BlanksAroundFences::default();
1044 let content = "::: {.outer}\n::: {.inner}\n```python\ncode\n```\n:::\n:::\n";
1045 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1046 let warnings = rule.check(&ctx).unwrap();
1047 assert!(
1048 warnings.is_empty(),
1049 "Nested divs with code blocks should have no warnings: {warnings:?}"
1050 );
1051 }
1052
1053 #[test]
1054 fn test_quarto_div_markers_in_standard_flavor() {
1055 let rule = MD031BlanksAroundFences::default();
1057 let content = "::: {.callout-note}\n```python\ncode\n```\n:::\n";
1058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1059 let warnings = rule.check(&ctx).unwrap();
1060 assert!(
1063 !warnings.is_empty(),
1064 "Standard flavor should require blanks around code blocks: {warnings:?}"
1065 );
1066 }
1067
1068 #[test]
1069 fn test_quarto_fix_does_not_add_blanks_at_div_boundaries() {
1070 let rule = MD031BlanksAroundFences::default();
1072 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1074 let fixed = rule.fix(&ctx).unwrap();
1075 assert_eq!(fixed, content, "Fix should not add blanks at Quarto div boundaries");
1077 }
1078
1079 #[test]
1080 fn test_quarto_code_block_with_content_before() {
1081 let rule = MD031BlanksAroundFences::default();
1083 let content = "::: {.callout-note}\nHere is some code:\n```python\ncode\n```\n:::";
1084 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1085 let warnings = rule.check(&ctx).unwrap();
1086 assert_eq!(
1088 warnings.len(),
1089 1,
1090 "Should require blank before code block inside div: {warnings:?}"
1091 );
1092 assert!(warnings[0].message.contains("before"));
1093 }
1094
1095 #[test]
1096 fn test_quarto_code_block_with_content_after() {
1097 let rule = MD031BlanksAroundFences::default();
1099 let content = "::: {.callout-note}\n```python\ncode\n```\nMore content here.\n:::";
1100 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1101 let warnings = rule.check(&ctx).unwrap();
1102 assert_eq!(
1104 warnings.len(),
1105 1,
1106 "Should require blank after code block inside div: {warnings:?}"
1107 );
1108 assert!(warnings[0].message.contains("after"));
1109 }
1110}