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 if ctx
245 .line_info(*opening_line + 1)
246 .is_some_and(|info| info.in_pymdown_block)
247 {
248 continue;
249 }
250
251 let prev_line_is_quarto_marker = *opening_line > 0 && is_quarto_div_marker(lines[*opening_line - 1]);
256 if *opening_line > 0
257 && !Self::is_effectively_empty_line(*opening_line - 1, &lines, ctx)
258 && !Self::is_right_after_frontmatter(*opening_line, ctx)
259 && !prev_line_is_quarto_marker
260 && self.should_require_blank_line(*opening_line, &lines)
261 {
262 let (start_line, start_col, end_line, end_col) =
263 calculate_line_range(*opening_line + 1, lines[*opening_line]);
264
265 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
266 warnings.push(LintWarning {
267 rule_name: Some(self.name().to_string()),
268 line: start_line,
269 column: start_col,
270 end_line,
271 end_column: end_col,
272 message: "No blank line before fenced code block".to_string(),
273 severity: Severity::Warning,
274 fix: Some(Fix {
275 range: line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
276 replacement: format!("{bq_prefix}\n"),
277 }),
278 });
279 }
280
281 let next_line_is_quarto_marker =
286 *closing_line + 1 < lines.len() && is_quarto_div_marker(lines[*closing_line + 1]);
287 if *closing_line + 1 < lines.len()
288 && !Self::is_effectively_empty_line(*closing_line + 1, &lines, ctx)
289 && !is_kramdown_block_attribute(lines[*closing_line + 1])
290 && !next_line_is_quarto_marker
291 && self.should_require_blank_line(*closing_line, &lines)
292 {
293 let (start_line, start_col, end_line, end_col) =
294 calculate_line_range(*closing_line + 1, lines[*closing_line]);
295
296 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
297 warnings.push(LintWarning {
298 rule_name: Some(self.name().to_string()),
299 line: start_line,
300 column: start_col,
301 end_line,
302 end_column: end_col,
303 message: "No blank line after fenced code block".to_string(),
304 severity: Severity::Warning,
305 fix: Some(Fix {
306 range: line_index.line_col_to_byte_range_with_length(
307 *closing_line + 1,
308 lines[*closing_line].len() + 1,
309 0,
310 ),
311 replacement: format!("{bq_prefix}\n"),
312 }),
313 });
314 }
315 }
316
317 if is_mkdocs {
319 let mut in_admonition = false;
320 let mut admonition_indent = 0;
321 let mut i = 0;
322
323 while i < lines.len() {
324 let line = lines[i];
325
326 let in_fenced_block = fenced_blocks.iter().any(|(start, end)| i >= *start && i <= *end);
328 if in_fenced_block {
329 i += 1;
330 continue;
331 }
332
333 if ctx.line_info(i + 1).is_some_and(|info| info.in_pymdown_block) {
335 i += 1;
336 continue;
337 }
338
339 if mkdocs_admonitions::is_admonition_start(line) {
341 if i > 0
343 && !Self::is_effectively_empty_line(i - 1, &lines, ctx)
344 && !Self::is_right_after_frontmatter(i, ctx)
345 && self.should_require_blank_line(i, &lines)
346 {
347 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
348
349 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
350 warnings.push(LintWarning {
351 rule_name: Some(self.name().to_string()),
352 line: start_line,
353 column: start_col,
354 end_line,
355 end_column: end_col,
356 message: "No blank line before admonition block".to_string(),
357 severity: Severity::Warning,
358 fix: Some(Fix {
359 range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
360 replacement: format!("{bq_prefix}\n"),
361 }),
362 });
363 }
364
365 in_admonition = true;
366 admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
367 i += 1;
368 continue;
369 }
370
371 if in_admonition
373 && !line.trim().is_empty()
374 && !mkdocs_admonitions::is_admonition_content(line, admonition_indent)
375 {
376 in_admonition = false;
377
378 if i > 0
382 && !Self::is_effectively_empty_line(i - 1, &lines, ctx)
383 && self.should_require_blank_line(i - 1, &lines)
384 {
385 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
386
387 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
388 warnings.push(LintWarning {
389 rule_name: Some(self.name().to_string()),
390 line: start_line,
391 column: start_col,
392 end_line,
393 end_column: end_col,
394 message: "No blank line after admonition block".to_string(),
395 severity: Severity::Warning,
396 fix: Some(Fix {
397 range: line_index.line_col_to_byte_range_with_length(i, 0, 0),
398 replacement: format!("{bq_prefix}\n"),
399 }),
400 });
401 }
402
403 admonition_indent = 0;
404 }
405
406 i += 1;
407 }
408 }
409
410 Ok(warnings)
411 }
412
413 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
414 let content = ctx.content;
415
416 let had_trailing_newline = content.ends_with('\n');
418
419 let lines: Vec<&str> = content.lines().collect();
420 let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
421
422 let is_quarto_div_marker =
424 |line: &str| -> bool { is_quarto && (quarto_divs::is_div_open(line) || quarto_divs::is_div_close(line)) };
425
426 let fenced_blocks = Self::detect_fenced_code_blocks_pulldown(content, &ctx.line_offsets, &lines);
428
429 let mut needs_blank_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
431 let mut needs_blank_after: std::collections::HashSet<usize> = std::collections::HashSet::new();
432
433 for (opening_line, closing_line) in &fenced_blocks {
434 let prev_line_is_quarto_marker = *opening_line > 0 && is_quarto_div_marker(lines[*opening_line - 1]);
438 if *opening_line > 0
439 && !Self::is_effectively_empty_line(*opening_line - 1, &lines, ctx)
440 && !Self::is_right_after_frontmatter(*opening_line, ctx)
441 && !prev_line_is_quarto_marker
442 && self.should_require_blank_line(*opening_line, &lines)
443 {
444 needs_blank_before.insert(*opening_line);
445 }
446
447 let next_line_is_quarto_marker =
451 *closing_line + 1 < lines.len() && is_quarto_div_marker(lines[*closing_line + 1]);
452 if *closing_line + 1 < lines.len()
453 && !Self::is_effectively_empty_line(*closing_line + 1, &lines, ctx)
454 && !is_kramdown_block_attribute(lines[*closing_line + 1])
455 && !next_line_is_quarto_marker
456 && self.should_require_blank_line(*closing_line, &lines)
457 {
458 needs_blank_after.insert(*closing_line);
459 }
460 }
461
462 let mut result = Vec::new();
464 for (i, line) in lines.iter().enumerate() {
465 if needs_blank_before.contains(&i) {
467 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
468 result.push(bq_prefix);
469 }
470
471 result.push((*line).to_string());
472
473 if needs_blank_after.contains(&i) {
475 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
476 result.push(bq_prefix);
477 }
478 }
479
480 let fixed = result.join("\n");
481
482 let final_result = if had_trailing_newline && !fixed.ends_with('\n') {
484 format!("{fixed}\n")
485 } else {
486 fixed
487 };
488
489 Ok(final_result)
490 }
491
492 fn category(&self) -> RuleCategory {
494 RuleCategory::CodeBlock
495 }
496
497 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
499 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
501 }
502
503 fn as_any(&self) -> &dyn std::any::Any {
504 self
505 }
506
507 fn default_config_section(&self) -> Option<(String, toml::Value)> {
508 let default_config = MD031Config::default();
509 let json_value = serde_json::to_value(&default_config).ok()?;
510 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
511 if let toml::Value::Table(table) = toml_value {
512 if !table.is_empty() {
513 Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
514 } else {
515 None
516 }
517 } else {
518 None
519 }
520 }
521
522 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
523 where
524 Self: Sized,
525 {
526 let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
527 Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use crate::lint_context::LintContext;
535
536 #[test]
537 fn test_basic_functionality() {
538 let rule = MD031BlanksAroundFences::default();
539
540 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 let warnings = rule.check(&ctx).unwrap();
544 assert!(
545 warnings.is_empty(),
546 "Expected no warnings for properly formatted code blocks"
547 );
548
549 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
552 let warnings = rule.check(&ctx).unwrap();
553 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
554 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
555 assert!(
556 warnings[0].message.contains("before"),
557 "Warning should be about blank line before"
558 );
559
560 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563 let warnings = rule.check(&ctx).unwrap();
564 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
565 assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
566 assert!(
567 warnings[0].message.contains("after"),
568 "Warning should be about blank line after"
569 );
570
571 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574 let warnings = rule.check(&ctx).unwrap();
575 assert_eq!(
576 warnings.len(),
577 2,
578 "Expected 2 warnings for missing blank lines before and after"
579 );
580 }
581
582 #[test]
583 fn test_nested_code_blocks() {
584 let rule = MD031BlanksAroundFences::default();
585
586 let content = r#"````markdown
588```
589content
590```
591````"#;
592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593 let warnings = rule.check(&ctx).unwrap();
594 assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
595
596 let fixed = rule.fix(&ctx).unwrap();
598 assert_eq!(fixed, content, "Fix should not modify nested code blocks");
599 }
600
601 #[test]
602 fn test_nested_code_blocks_complex() {
603 let rule = MD031BlanksAroundFences::default();
604
605 let content = r#"# Documentation
607
608## Examples
609
610````markdown
611```python
612def hello():
613 print("Hello, world!")
614```
615
616```javascript
617console.log("Hello, world!");
618```
619````
620
621More text here."#;
622
623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624 let warnings = rule.check(&ctx).unwrap();
625 assert_eq!(
626 warnings.len(),
627 0,
628 "Should not flag any issues in properly formatted nested code blocks"
629 );
630
631 let content_5 = r#"`````markdown
633````python
634```bash
635echo "nested"
636```
637````
638`````"#;
639
640 let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
641 let warnings_5 = rule.check(&ctx_5).unwrap();
642 assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
643 }
644
645 #[test]
646 fn test_fix_preserves_trailing_newline() {
647 let rule = MD031BlanksAroundFences::default();
648
649 let content = "Some text\n```\ncode\n```\nMore text\n";
651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652 let fixed = rule.fix(&ctx).unwrap();
653
654 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
656 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
657 }
658
659 #[test]
660 fn test_fix_preserves_no_trailing_newline() {
661 let rule = MD031BlanksAroundFences::default();
662
663 let content = "Some text\n```\ncode\n```\nMore text";
665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666 let fixed = rule.fix(&ctx).unwrap();
667
668 assert!(
670 !fixed.ends_with('\n'),
671 "Fix should not add trailing newline if original didn't have one"
672 );
673 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
674 }
675
676 #[test]
677 fn test_list_items_config_true() {
678 let rule = MD031BlanksAroundFences::new(true);
680
681 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
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_list_items_config_false() {
693 let rule = MD031BlanksAroundFences::new(false);
695
696 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
698 let warnings = rule.check(&ctx).unwrap();
699
700 assert_eq!(warnings.len(), 0);
702 }
703
704 #[test]
705 fn test_list_items_config_false_outside_list() {
706 let rule = MD031BlanksAroundFences::new(false);
708
709 let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711 let warnings = rule.check(&ctx).unwrap();
712
713 assert_eq!(warnings.len(), 2);
715 assert!(warnings[0].message.contains("before"));
716 assert!(warnings[1].message.contains("after"));
717 }
718
719 #[test]
720 fn test_default_config_section() {
721 let rule = MD031BlanksAroundFences::default();
722 let config_section = rule.default_config_section();
723
724 assert!(config_section.is_some());
725 let (name, value) = config_section.unwrap();
726 assert_eq!(name, "MD031");
727
728 if let toml::Value::Table(table) = value {
730 assert!(table.contains_key("list-items"));
731 assert_eq!(table["list-items"], toml::Value::Boolean(true));
732 } else {
733 panic!("Expected TOML table");
734 }
735 }
736
737 #[test]
738 fn test_fix_list_items_config_false() {
739 let rule = MD031BlanksAroundFences::new(false);
741
742 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744 let fixed = rule.fix(&ctx).unwrap();
745
746 assert_eq!(fixed, content);
748 }
749
750 #[test]
751 fn test_fix_list_items_config_true() {
752 let rule = MD031BlanksAroundFences::new(true);
754
755 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757 let fixed = rule.fix(&ctx).unwrap();
758
759 let expected = "1. First item\n\n ```python\n code()\n ```\n\n2. Second item";
761 assert_eq!(fixed, expected);
762 }
763
764 #[test]
765 fn test_no_warning_after_frontmatter() {
766 let rule = MD031BlanksAroundFences::default();
769
770 let content = "---\ntitle: Test\n---\n```\ncode\n```";
771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
772 let warnings = rule.check(&ctx).unwrap();
773
774 assert!(
776 warnings.is_empty(),
777 "Expected no warnings for code block after frontmatter, got: {warnings:?}"
778 );
779 }
780
781 #[test]
782 fn test_fix_does_not_add_blank_after_frontmatter() {
783 let rule = MD031BlanksAroundFences::default();
785
786 let content = "---\ntitle: Test\n---\n```\ncode\n```";
787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
788 let fixed = rule.fix(&ctx).unwrap();
789
790 assert_eq!(fixed, content);
792 }
793
794 #[test]
795 fn test_frontmatter_with_blank_line_before_code() {
796 let rule = MD031BlanksAroundFences::default();
798
799 let content = "---\ntitle: Test\n---\n\n```\ncode\n```";
800 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801 let warnings = rule.check(&ctx).unwrap();
802
803 assert!(warnings.is_empty());
804 }
805
806 #[test]
807 fn test_no_warning_for_admonition_after_frontmatter() {
808 let rule = MD031BlanksAroundFences::default();
810
811 let content = "---\ntitle: Test\n---\n!!! note\n This is a note";
812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
813 let warnings = rule.check(&ctx).unwrap();
814
815 assert!(
816 warnings.is_empty(),
817 "Expected no warnings for admonition after frontmatter, got: {warnings:?}"
818 );
819 }
820
821 #[test]
822 fn test_toml_frontmatter_before_code() {
823 let rule = MD031BlanksAroundFences::default();
825
826 let content = "+++\ntitle = \"Test\"\n+++\n```\ncode\n```";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let warnings = rule.check(&ctx).unwrap();
829
830 assert!(
831 warnings.is_empty(),
832 "Expected no warnings for code block after TOML frontmatter, got: {warnings:?}"
833 );
834 }
835
836 #[test]
837 fn test_fenced_code_in_list_with_4_space_indent_issue_276() {
838 let rule = MD031BlanksAroundFences::new(true);
842
843 let content =
845 "1. First item\n2. Second item with code:\n ```python\n print(\"Hello\")\n ```\n3. Third item";
846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
847 let warnings = rule.check(&ctx).unwrap();
848
849 assert_eq!(
851 warnings.len(),
852 2,
853 "Should detect fenced code in list with 4-space indent, got: {warnings:?}"
854 );
855 assert!(warnings[0].message.contains("before"));
856 assert!(warnings[1].message.contains("after"));
857
858 let fixed = rule.fix(&ctx).unwrap();
860 let expected =
861 "1. First item\n2. Second item with code:\n\n ```python\n print(\"Hello\")\n ```\n\n3. Third item";
862 assert_eq!(
863 fixed, expected,
864 "Fix should add blank lines around list-indented fenced code"
865 );
866 }
867
868 #[test]
869 fn test_fenced_code_in_list_with_mixed_indentation() {
870 let rule = MD031BlanksAroundFences::new(true);
872
873 let content = r#"# Test
874
8753-space indent:
8761. First item
877 ```python
878 code
879 ```
8802. Second item
881
8824-space indent:
8831. First item
884 ```python
885 code
886 ```
8872. Second item"#;
888
889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
890 let warnings = rule.check(&ctx).unwrap();
891
892 assert_eq!(
894 warnings.len(),
895 4,
896 "Should detect all fenced code blocks regardless of indentation, got: {warnings:?}"
897 );
898 }
899
900 #[test]
901 fn test_fix_preserves_blockquote_prefix_before_fence() {
902 let rule = MD031BlanksAroundFences::default();
904
905 let content = "> Text before
906> ```
907> code
908> ```";
909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
910 let fixed = rule.fix(&ctx).unwrap();
911
912 let expected = "> Text before
914>
915> ```
916> code
917> ```";
918 assert_eq!(
919 fixed, expected,
920 "Fix should insert '>' blank line, not plain blank line"
921 );
922 }
923
924 #[test]
925 fn test_fix_preserves_blockquote_prefix_after_fence() {
926 let rule = MD031BlanksAroundFences::default();
928
929 let content = "> ```
930> code
931> ```
932> Text after";
933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
934 let fixed = rule.fix(&ctx).unwrap();
935
936 let expected = "> ```
938> code
939> ```
940>
941> Text after";
942 assert_eq!(
943 fixed, expected,
944 "Fix should insert '>' blank line after fence, not plain blank line"
945 );
946 }
947
948 #[test]
949 fn test_fix_preserves_nested_blockquote_prefix() {
950 let rule = MD031BlanksAroundFences::default();
952
953 let content = ">> Nested quote
954>> ```
955>> code
956>> ```
957>> More text";
958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
959 let fixed = rule.fix(&ctx).unwrap();
960
961 let expected = ">> Nested quote
963>>
964>> ```
965>> code
966>> ```
967>>
968>> More text";
969 assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
970 }
971
972 #[test]
973 fn test_fix_preserves_triple_nested_blockquote_prefix() {
974 let rule = MD031BlanksAroundFences::default();
976
977 let content = ">>> Triple nested
978>>> ```
979>>> code
980>>> ```
981>>> More text";
982 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
983 let fixed = rule.fix(&ctx).unwrap();
984
985 let expected = ">>> Triple nested
986>>>
987>>> ```
988>>> code
989>>> ```
990>>>
991>>> More text";
992 assert_eq!(
993 fixed, expected,
994 "Fix should preserve triple-nested blockquote prefix '>>>'"
995 );
996 }
997
998 #[test]
1001 fn test_quarto_code_block_after_div_open() {
1002 let rule = MD031BlanksAroundFences::default();
1004 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1005 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1006 let warnings = rule.check(&ctx).unwrap();
1007 assert!(
1008 warnings.is_empty(),
1009 "Should not require blank line after Quarto div opening: {warnings:?}"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_quarto_code_block_before_div_close() {
1015 let rule = MD031BlanksAroundFences::default();
1017 let content = "::: {.callout-note}\nSome text\n```python\ncode\n```\n:::";
1018 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1019 let warnings = rule.check(&ctx).unwrap();
1020 assert!(
1022 warnings.len() <= 1,
1023 "Should not require blank line before Quarto div closing: {warnings:?}"
1024 );
1025 }
1026
1027 #[test]
1028 fn test_quarto_code_block_outside_div_still_requires_blanks() {
1029 let rule = MD031BlanksAroundFences::default();
1031 let content = "Some text\n```python\ncode\n```\nMore text";
1032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1033 let warnings = rule.check(&ctx).unwrap();
1034 assert_eq!(
1035 warnings.len(),
1036 2,
1037 "Should still require blank lines around code blocks outside divs"
1038 );
1039 }
1040
1041 #[test]
1042 fn test_quarto_code_block_with_callout_note() {
1043 let rule = MD031BlanksAroundFences::default();
1045 let content = "::: {.callout-note}\n```r\n1 + 1\n```\n:::\n\nMore text";
1046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1047 let warnings = rule.check(&ctx).unwrap();
1048 assert!(
1049 warnings.is_empty(),
1050 "Callout note with code block should have no warnings: {warnings:?}"
1051 );
1052 }
1053
1054 #[test]
1055 fn test_quarto_nested_divs_with_code() {
1056 let rule = MD031BlanksAroundFences::default();
1058 let content = "::: {.outer}\n::: {.inner}\n```python\ncode\n```\n:::\n:::\n";
1059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1060 let warnings = rule.check(&ctx).unwrap();
1061 assert!(
1062 warnings.is_empty(),
1063 "Nested divs with code blocks should have no warnings: {warnings:?}"
1064 );
1065 }
1066
1067 #[test]
1068 fn test_quarto_div_markers_in_standard_flavor() {
1069 let rule = MD031BlanksAroundFences::default();
1071 let content = "::: {.callout-note}\n```python\ncode\n```\n:::\n";
1072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073 let warnings = rule.check(&ctx).unwrap();
1074 assert!(
1077 !warnings.is_empty(),
1078 "Standard flavor should require blanks around code blocks: {warnings:?}"
1079 );
1080 }
1081
1082 #[test]
1083 fn test_quarto_fix_does_not_add_blanks_at_div_boundaries() {
1084 let rule = MD031BlanksAroundFences::default();
1086 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1087 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1088 let fixed = rule.fix(&ctx).unwrap();
1089 assert_eq!(fixed, content, "Fix should not add blanks at Quarto div boundaries");
1091 }
1092
1093 #[test]
1094 fn test_quarto_code_block_with_content_before() {
1095 let rule = MD031BlanksAroundFences::default();
1097 let content = "::: {.callout-note}\nHere is some code:\n```python\ncode\n```\n:::";
1098 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1099 let warnings = rule.check(&ctx).unwrap();
1100 assert_eq!(
1102 warnings.len(),
1103 1,
1104 "Should require blank before code block inside div: {warnings:?}"
1105 );
1106 assert!(warnings[0].message.contains("before"));
1107 }
1108
1109 #[test]
1110 fn test_quarto_code_block_with_content_after() {
1111 let rule = MD031BlanksAroundFences::default();
1113 let content = "::: {.callout-note}\n```python\ncode\n```\nMore content here.\n:::";
1114 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1115 let warnings = rule.check(&ctx).unwrap();
1116 assert_eq!(
1118 warnings.len(),
1119 1,
1120 "Should require blank after code block inside div: {warnings:?}"
1121 );
1122 assert!(warnings[0].message.contains("after"));
1123 }
1124}