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 fn myst_directive_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
201 ctx.myst_directive_ranges()
202 .iter()
203 .map(|&(start, end)| {
204 let start_line = ctx.line_offsets.partition_point(|&off| off <= start).saturating_sub(1);
205 let end_byte = if end > 0 { end - 1 } else { 0 };
206 let end_line = ctx
207 .line_offsets
208 .partition_point(|&off| off <= end_byte)
209 .saturating_sub(1);
210 (start_line, end_line)
211 })
212 .collect()
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 line_index = &ctx.line_index;
227
228 let mut warnings = Vec::new();
229 let lines = ctx.raw_lines();
230 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
231 let is_pandoc = ctx.flavor.is_pandoc_compatible();
232
233 let fenced_blocks = Self::fenced_block_line_ranges(ctx);
235
236 let is_pandoc_div_marker =
238 |line: &str| -> bool { is_pandoc && (pandoc::is_div_open(line) || pandoc::is_div_close(line)) };
239
240 for (opening_line, closing_line) in &fenced_blocks {
242 if ctx
244 .line_info(*opening_line + 1)
245 .is_some_and(|info| info.in_pymdown_block)
246 {
247 continue;
248 }
249
250 let prev_line_is_pandoc_marker = *opening_line > 0 && is_pandoc_div_marker(lines[*opening_line - 1]);
255 if *opening_line > 0
256 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
257 && !Self::is_right_after_frontmatter(*opening_line, ctx)
258 && !prev_line_is_pandoc_marker
259 && self.should_require_blank_line(*opening_line, lines)
260 {
261 let (start_line, start_col, end_line, end_col) =
262 calculate_line_range(*opening_line + 1, lines[*opening_line]);
263
264 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
265 warnings.push(LintWarning {
266 rule_name: Some(self.name().to_string()),
267 line: start_line,
268 column: start_col,
269 end_line,
270 end_column: end_col,
271 message: "No blank line before fenced code block".to_string(),
272 severity: Severity::Warning,
273 fix: Some(Fix::new(
274 line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
275 format!("{bq_prefix}\n"),
276 )),
277 });
278 }
279
280 let next_line_is_pandoc_marker =
285 *closing_line + 1 < lines.len() && is_pandoc_div_marker(lines[*closing_line + 1]);
286 if *closing_line + 1 < lines.len()
287 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
288 && !is_kramdown_block_attribute(lines[*closing_line + 1])
289 && !next_line_is_pandoc_marker
290 && self.should_require_blank_line(*closing_line, lines)
291 {
292 let (start_line, start_col, end_line, end_col) =
293 calculate_line_range(*closing_line + 1, lines[*closing_line]);
294
295 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
296 warnings.push(LintWarning {
297 rule_name: Some(self.name().to_string()),
298 line: start_line,
299 column: start_col,
300 end_line,
301 end_column: end_col,
302 message: "No blank line after fenced code block".to_string(),
303 severity: Severity::Warning,
304 fix: Some(Fix::new(
305 line_index.line_col_to_byte_range_with_length(*closing_line + 2, 1, 0),
306 format!("{bq_prefix}\n"),
307 )),
308 });
309 }
310 }
311
312 if ctx.flavor.supports_colon_code_fences() {
314 let colon_blocks = Self::colon_fence_line_ranges(ctx);
315 for (opening_line, closing_line) in &colon_blocks {
316 if *opening_line > 0
318 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
319 && !Self::is_right_after_frontmatter(*opening_line, ctx)
320 && self.should_require_blank_line(*opening_line, lines)
321 {
322 let (start_line, start_col, end_line, end_col) =
323 calculate_line_range(*opening_line + 1, lines[*opening_line]);
324 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
325 warnings.push(LintWarning {
326 rule_name: Some(self.name().to_string()),
327 line: start_line,
328 column: start_col,
329 end_line,
330 end_column: end_col,
331 message: "No blank line before colon code fence".to_string(),
332 severity: Severity::Warning,
333 fix: Some(Fix::new(
334 line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
335 format!("{bq_prefix}\n"),
336 )),
337 });
338 }
339
340 if *closing_line + 1 < lines.len()
342 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
343 && self.should_require_blank_line(*closing_line, lines)
344 {
345 let (start_line, start_col, end_line, end_col) =
346 calculate_line_range(*closing_line + 1, lines[*closing_line]);
347 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
348 warnings.push(LintWarning {
349 rule_name: Some(self.name().to_string()),
350 line: start_line,
351 column: start_col,
352 end_line,
353 end_column: end_col,
354 message: "No blank line after colon code fence".to_string(),
355 severity: Severity::Warning,
356 fix: Some(Fix::new(
357 line_index.line_col_to_byte_range_with_length(*closing_line + 2, 1, 0),
358 format!("{bq_prefix}\n"),
359 )),
360 });
361 }
362 }
363 }
364
365 if ctx.flavor.supports_myst_directives() {
367 let myst_blocks = Self::myst_directive_line_ranges(ctx);
368 for (opening_line, closing_line) in &myst_blocks {
369 if *opening_line > 0
371 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
372 && !Self::is_right_after_frontmatter(*opening_line, ctx)
373 && self.should_require_blank_line(*opening_line, lines)
374 {
375 let (start_line, start_col, end_line, end_col) =
376 calculate_line_range(*opening_line + 1, lines[*opening_line]);
377 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
378 warnings.push(LintWarning {
379 rule_name: Some(self.name().to_string()),
380 line: start_line,
381 column: start_col,
382 end_line,
383 end_column: end_col,
384 message: "No blank line before MyST directive".to_string(),
385 severity: Severity::Warning,
386 fix: Some(Fix::new(
387 line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
388 format!("{bq_prefix}\n"),
389 )),
390 });
391 }
392
393 if *closing_line + 1 < lines.len()
395 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
396 && self.should_require_blank_line(*closing_line, lines)
397 {
398 let (start_line, start_col, end_line, end_col) =
399 calculate_line_range(*closing_line + 1, lines[*closing_line]);
400 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
401 warnings.push(LintWarning {
402 rule_name: Some(self.name().to_string()),
403 line: start_line,
404 column: start_col,
405 end_line,
406 end_column: end_col,
407 message: "No blank line after MyST directive".to_string(),
408 severity: Severity::Warning,
409 fix: Some(Fix::new(
410 line_index.line_col_to_byte_range_with_length(*closing_line + 2, 1, 0),
411 format!("{bq_prefix}\n"),
412 )),
413 });
414 }
415 }
416 }
417
418 if is_mkdocs {
420 let mut in_admonition = false;
421 let mut admonition_indent = 0;
422 let mut i = 0;
423
424 while i < lines.len() {
425 let line = lines[i];
426
427 let in_fenced_block = fenced_blocks.iter().any(|(start, end)| i >= *start && i <= *end);
429 if in_fenced_block {
430 i += 1;
431 continue;
432 }
433
434 if ctx.line_info(i + 1).is_some_and(|info| info.in_pymdown_block) {
436 i += 1;
437 continue;
438 }
439
440 if mkdocs_admonitions::is_admonition_start(line) {
442 if i > 0
444 && !Self::is_effectively_empty_line(i - 1, lines, ctx)
445 && !Self::is_right_after_frontmatter(i, ctx)
446 && self.should_require_blank_line(i, lines)
447 {
448 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
449
450 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
451 warnings.push(LintWarning {
452 rule_name: Some(self.name().to_string()),
453 line: start_line,
454 column: start_col,
455 end_line,
456 end_column: end_col,
457 message: "No blank line before admonition block".to_string(),
458 severity: Severity::Warning,
459 fix: Some(Fix::new(
460 line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
461 format!("{bq_prefix}\n"),
462 )),
463 });
464 }
465
466 in_admonition = true;
467 admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
468 i += 1;
469 continue;
470 }
471
472 if in_admonition
474 && !line.trim().is_empty()
475 && !mkdocs_admonitions::is_admonition_content(line, admonition_indent)
476 {
477 in_admonition = false;
478
479 if i > 0
483 && !Self::is_effectively_empty_line(i - 1, lines, ctx)
484 && self.should_require_blank_line(i - 1, lines)
485 {
486 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
487
488 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
489 warnings.push(LintWarning {
490 rule_name: Some(self.name().to_string()),
491 line: start_line,
492 column: start_col,
493 end_line,
494 end_column: end_col,
495 message: "No blank line after admonition block".to_string(),
496 severity: Severity::Warning,
497 fix: Some(Fix::new(
498 line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
499 format!("{bq_prefix}\n"),
500 )),
501 });
502 }
503
504 admonition_indent = 0;
505 }
506
507 i += 1;
508 }
509 }
510
511 Ok(warnings)
512 }
513
514 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
515 if self.should_skip(ctx) {
516 return Ok(ctx.content.to_string());
517 }
518 let warnings = self.check(ctx)?;
519 if warnings.is_empty() {
520 return Ok(ctx.content.to_string());
521 }
522 let warnings =
523 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
524 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
525 .map_err(crate::rule::LintError::InvalidInput)
526 }
527
528 fn category(&self) -> RuleCategory {
530 RuleCategory::CodeBlock
531 }
532
533 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
535 if ctx.content.is_empty() {
536 return true;
537 }
538 let has_fences = ctx.likely_has_code() || ctx.has_char('~');
539 let has_mkdocs_admonitions = ctx.flavor == crate::config::MarkdownFlavor::MkDocs && ctx.content.contains("!!!");
540 let has_colon_fences = ctx.flavor.supports_colon_code_fences() && ctx.content.contains(":::");
541 let has_myst_directives = ctx.flavor.supports_myst_directives() && ctx.content.contains(":::");
542 !has_fences && !has_mkdocs_admonitions && !has_colon_fences && !has_myst_directives
543 }
544
545 fn as_any(&self) -> &dyn std::any::Any {
546 self
547 }
548
549 fn default_config_section(&self) -> Option<(String, toml::Value)> {
550 let default_config = MD031Config::default();
551 let json_value = serde_json::to_value(&default_config).ok()?;
552 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
553 if let toml::Value::Table(table) = toml_value {
554 if !table.is_empty() {
555 Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
556 } else {
557 None
558 }
559 } else {
560 None
561 }
562 }
563
564 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
565 where
566 Self: Sized,
567 {
568 let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
569 Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use crate::lint_context::LintContext;
577
578 #[test]
579 fn test_basic_functionality() {
580 let rule = MD031BlanksAroundFences::default();
581
582 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585 let warnings = rule.check(&ctx).unwrap();
586 assert!(
587 warnings.is_empty(),
588 "Expected no warnings for properly formatted code blocks"
589 );
590
591 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594 let warnings = rule.check(&ctx).unwrap();
595 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
596 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
597 assert!(
598 warnings[0].message.contains("before"),
599 "Warning should be about blank line before"
600 );
601
602 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605 let warnings = rule.check(&ctx).unwrap();
606 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
607 assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
608 assert!(
609 warnings[0].message.contains("after"),
610 "Warning should be about blank line after"
611 );
612
613 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
616 let warnings = rule.check(&ctx).unwrap();
617 assert_eq!(
618 warnings.len(),
619 2,
620 "Expected 2 warnings for missing blank lines before and after"
621 );
622 }
623
624 #[test]
625 fn test_nested_code_blocks() {
626 let rule = MD031BlanksAroundFences::default();
627
628 let content = r#"````markdown
630```
631content
632```
633````"#;
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635 let warnings = rule.check(&ctx).unwrap();
636 assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
637
638 let fixed = rule.fix(&ctx).unwrap();
640 assert_eq!(fixed, content, "Fix should not modify nested code blocks");
641 }
642
643 #[test]
644 fn test_nested_code_blocks_complex() {
645 let rule = MD031BlanksAroundFences::default();
646
647 let content = r#"# Documentation
649
650## Examples
651
652````markdown
653```python
654def hello():
655 print("Hello, world!")
656```
657
658```javascript
659console.log("Hello, world!");
660```
661````
662
663More text here."#;
664
665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666 let warnings = rule.check(&ctx).unwrap();
667 assert_eq!(
668 warnings.len(),
669 0,
670 "Should not flag any issues in properly formatted nested code blocks"
671 );
672
673 let content_5 = r#"`````markdown
675````python
676```bash
677echo "nested"
678```
679````
680`````"#;
681
682 let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
683 let warnings_5 = rule.check(&ctx_5).unwrap();
684 assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
685 }
686
687 #[test]
688 fn test_fix_preserves_trailing_newline() {
689 let rule = MD031BlanksAroundFences::default();
690
691 let content = "Some text\n```\ncode\n```\nMore text\n";
693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694 let fixed = rule.fix(&ctx).unwrap();
695
696 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
698 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
699 }
700
701 #[test]
702 fn test_fix_preserves_no_trailing_newline() {
703 let rule = MD031BlanksAroundFences::default();
704
705 let content = "Some text\n```\ncode\n```\nMore text";
707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
708 let fixed = rule.fix(&ctx).unwrap();
709
710 assert!(
712 !fixed.ends_with('\n'),
713 "Fix should not add trailing newline if original didn't have one"
714 );
715 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
716 }
717
718 #[test]
719 fn test_list_items_config_true() {
720 let rule = MD031BlanksAroundFences::new(true);
722
723 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
724 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
725 let warnings = rule.check(&ctx).unwrap();
726
727 assert_eq!(warnings.len(), 2);
729 assert!(warnings[0].message.contains("before"));
730 assert!(warnings[1].message.contains("after"));
731 }
732
733 #[test]
734 fn test_list_items_config_false() {
735 let rule = MD031BlanksAroundFences::new(false);
737
738 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740 let warnings = rule.check(&ctx).unwrap();
741
742 assert_eq!(warnings.len(), 0);
744 }
745
746 #[test]
747 fn test_list_items_config_false_outside_list() {
748 let rule = MD031BlanksAroundFences::new(false);
750
751 let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
753 let warnings = rule.check(&ctx).unwrap();
754
755 assert_eq!(warnings.len(), 2);
757 assert!(warnings[0].message.contains("before"));
758 assert!(warnings[1].message.contains("after"));
759 }
760
761 #[test]
762 fn test_default_config_section() {
763 let rule = MD031BlanksAroundFences::default();
764 let config_section = rule.default_config_section();
765
766 assert!(config_section.is_some());
767 let (name, value) = config_section.unwrap();
768 assert_eq!(name, "MD031");
769
770 if let toml::Value::Table(table) = value {
772 assert!(table.contains_key("list-items"));
773 assert_eq!(table["list-items"], toml::Value::Boolean(true));
774 } else {
775 panic!("Expected TOML table");
776 }
777 }
778
779 #[test]
780 fn test_fix_list_items_config_false() {
781 let rule = MD031BlanksAroundFences::new(false);
783
784 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let fixed = rule.fix(&ctx).unwrap();
787
788 assert_eq!(fixed, content);
790 }
791
792 #[test]
793 fn test_fix_list_items_config_true() {
794 let rule = MD031BlanksAroundFences::new(true);
796
797 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799 let fixed = rule.fix(&ctx).unwrap();
800
801 let expected = "1. First item\n\n ```python\n code()\n ```\n\n2. Second item";
803 assert_eq!(fixed, expected);
804 }
805
806 #[test]
807 fn test_no_warning_after_frontmatter() {
808 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!(
818 warnings.is_empty(),
819 "Expected no warnings for code block after frontmatter, got: {warnings:?}"
820 );
821 }
822
823 #[test]
824 fn test_fix_does_not_add_blank_after_frontmatter() {
825 let rule = MD031BlanksAroundFences::default();
827
828 let content = "---\ntitle: Test\n---\n```\ncode\n```";
829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
830 let fixed = rule.fix(&ctx).unwrap();
831
832 assert_eq!(fixed, content);
834 }
835
836 #[test]
837 fn test_frontmatter_with_blank_line_before_code() {
838 let rule = MD031BlanksAroundFences::default();
840
841 let content = "---\ntitle: Test\n---\n\n```\ncode\n```";
842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
843 let warnings = rule.check(&ctx).unwrap();
844
845 assert!(warnings.is_empty());
846 }
847
848 #[test]
849 fn test_no_warning_for_admonition_after_frontmatter() {
850 let rule = MD031BlanksAroundFences::default();
852
853 let content = "---\ntitle: Test\n---\n!!! note\n This is a note";
854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
855 let warnings = rule.check(&ctx).unwrap();
856
857 assert!(
858 warnings.is_empty(),
859 "Expected no warnings for admonition after frontmatter, got: {warnings:?}"
860 );
861 }
862
863 #[test]
864 fn test_toml_frontmatter_before_code() {
865 let rule = MD031BlanksAroundFences::default();
867
868 let content = "+++\ntitle = \"Test\"\n+++\n```\ncode\n```";
869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
870 let warnings = rule.check(&ctx).unwrap();
871
872 assert!(
873 warnings.is_empty(),
874 "Expected no warnings for code block after TOML frontmatter, got: {warnings:?}"
875 );
876 }
877
878 #[test]
879 fn test_fenced_code_in_list_with_4_space_indent_issue_276() {
880 let rule = MD031BlanksAroundFences::new(true);
884
885 let content =
887 "1. First item\n2. Second item with code:\n ```python\n print(\"Hello\")\n ```\n3. Third item";
888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889 let warnings = rule.check(&ctx).unwrap();
890
891 assert_eq!(
893 warnings.len(),
894 2,
895 "Should detect fenced code in list with 4-space indent, got: {warnings:?}"
896 );
897 assert!(warnings[0].message.contains("before"));
898 assert!(warnings[1].message.contains("after"));
899
900 let fixed = rule.fix(&ctx).unwrap();
902 let expected =
903 "1. First item\n2. Second item with code:\n\n ```python\n print(\"Hello\")\n ```\n\n3. Third item";
904 assert_eq!(
905 fixed, expected,
906 "Fix should add blank lines around list-indented fenced code"
907 );
908 }
909
910 #[test]
911 fn test_fenced_code_in_list_with_mixed_indentation() {
912 let rule = MD031BlanksAroundFences::new(true);
914
915 let content = r#"# Test
916
9173-space indent:
9181. First item
919 ```python
920 code
921 ```
9222. Second item
923
9244-space indent:
9251. First item
926 ```python
927 code
928 ```
9292. Second item"#;
930
931 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
932 let warnings = rule.check(&ctx).unwrap();
933
934 assert_eq!(
936 warnings.len(),
937 4,
938 "Should detect all fenced code blocks regardless of indentation, got: {warnings:?}"
939 );
940 }
941
942 #[test]
943 fn test_fix_preserves_blockquote_prefix_before_fence() {
944 let rule = MD031BlanksAroundFences::default();
946
947 let content = "> Text before
948> ```
949> code
950> ```";
951 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
952 let fixed = rule.fix(&ctx).unwrap();
953
954 let expected = "> Text before
956>
957> ```
958> code
959> ```";
960 assert_eq!(
961 fixed, expected,
962 "Fix should insert '>' blank line, not plain blank line"
963 );
964 }
965
966 #[test]
967 fn test_fix_preserves_blockquote_prefix_after_fence() {
968 let rule = MD031BlanksAroundFences::default();
970
971 let content = "> ```
972> code
973> ```
974> Text after";
975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976 let fixed = rule.fix(&ctx).unwrap();
977
978 let expected = "> ```
980> code
981> ```
982>
983> Text after";
984 assert_eq!(
985 fixed, expected,
986 "Fix should insert '>' blank line after fence, not plain blank line"
987 );
988 }
989
990 #[test]
991 fn test_fix_preserves_nested_blockquote_prefix() {
992 let rule = MD031BlanksAroundFences::default();
994
995 let content = ">> Nested quote
996>> ```
997>> code
998>> ```
999>> More text";
1000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1001 let fixed = rule.fix(&ctx).unwrap();
1002
1003 let expected = ">> Nested quote
1005>>
1006>> ```
1007>> code
1008>> ```
1009>>
1010>> More text";
1011 assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
1012 }
1013
1014 #[test]
1015 fn test_fix_preserves_triple_nested_blockquote_prefix() {
1016 let rule = MD031BlanksAroundFences::default();
1018
1019 let content = ">>> Triple nested
1020>>> ```
1021>>> code
1022>>> ```
1023>>> More text";
1024 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1025 let fixed = rule.fix(&ctx).unwrap();
1026
1027 let expected = ">>> Triple nested
1028>>>
1029>>> ```
1030>>> code
1031>>> ```
1032>>>
1033>>> More text";
1034 assert_eq!(
1035 fixed, expected,
1036 "Fix should preserve triple-nested blockquote prefix '>>>'"
1037 );
1038 }
1039
1040 #[test]
1043 fn test_quarto_code_block_after_div_open() {
1044 let rule = MD031BlanksAroundFences::default();
1046 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1047 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1048 let warnings = rule.check(&ctx).unwrap();
1049 assert!(
1050 warnings.is_empty(),
1051 "Should not require blank line after Quarto div opening: {warnings:?}"
1052 );
1053 }
1054
1055 #[test]
1056 fn test_quarto_code_block_before_div_close() {
1057 let rule = MD031BlanksAroundFences::default();
1059 let content = "::: {.callout-note}\nSome text\n```python\ncode\n```\n:::";
1060 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1061 let warnings = rule.check(&ctx).unwrap();
1062 assert!(
1064 warnings.len() <= 1,
1065 "Should not require blank line before Quarto div closing: {warnings:?}"
1066 );
1067 }
1068
1069 #[test]
1070 fn test_quarto_code_block_outside_div_still_requires_blanks() {
1071 let rule = MD031BlanksAroundFences::default();
1073 let content = "Some text\n```python\ncode\n```\nMore text";
1074 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1075 let warnings = rule.check(&ctx).unwrap();
1076 assert_eq!(
1077 warnings.len(),
1078 2,
1079 "Should still require blank lines around code blocks outside divs"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_quarto_code_block_with_callout_note() {
1085 let rule = MD031BlanksAroundFences::default();
1087 let content = "::: {.callout-note}\n```r\n1 + 1\n```\n:::\n\nMore text";
1088 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1089 let warnings = rule.check(&ctx).unwrap();
1090 assert!(
1091 warnings.is_empty(),
1092 "Callout note with code block should have no warnings: {warnings:?}"
1093 );
1094 }
1095
1096 #[test]
1097 fn test_quarto_nested_divs_with_code() {
1098 let rule = MD031BlanksAroundFences::default();
1100 let content = "::: {.outer}\n::: {.inner}\n```python\ncode\n```\n:::\n:::\n";
1101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1102 let warnings = rule.check(&ctx).unwrap();
1103 assert!(
1104 warnings.is_empty(),
1105 "Nested divs with code blocks should have no warnings: {warnings:?}"
1106 );
1107 }
1108
1109 #[test]
1110 fn test_quarto_div_markers_in_standard_flavor() {
1111 let rule = MD031BlanksAroundFences::default();
1113 let content = "::: {.callout-note}\n```python\ncode\n```\n:::\n";
1114 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1115 let warnings = rule.check(&ctx).unwrap();
1116 assert!(
1119 !warnings.is_empty(),
1120 "Standard flavor should require blanks around code blocks: {warnings:?}"
1121 );
1122 }
1123
1124 #[test]
1125 fn test_quarto_fix_does_not_add_blanks_at_div_boundaries() {
1126 let rule = MD031BlanksAroundFences::default();
1128 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1130 let fixed = rule.fix(&ctx).unwrap();
1131 assert_eq!(fixed, content, "Fix should not add blanks at Quarto div boundaries");
1133 }
1134
1135 #[test]
1136 fn test_quarto_code_block_with_content_before() {
1137 let rule = MD031BlanksAroundFences::default();
1139 let content = "::: {.callout-note}\nHere is some code:\n```python\ncode\n```\n:::";
1140 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1141 let warnings = rule.check(&ctx).unwrap();
1142 assert_eq!(
1144 warnings.len(),
1145 1,
1146 "Should require blank before code block inside div: {warnings:?}"
1147 );
1148 assert!(warnings[0].message.contains("before"));
1149 }
1150
1151 #[test]
1152 fn test_quarto_code_block_with_content_after() {
1153 let rule = MD031BlanksAroundFences::default();
1155 let content = "::: {.callout-note}\n```python\ncode\n```\nMore content here.\n:::";
1156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1157 let warnings = rule.check(&ctx).unwrap();
1158 assert_eq!(
1160 warnings.len(),
1161 1,
1162 "Should require blank after code block inside div: {warnings:?}"
1163 );
1164 assert!(warnings[0].message.contains("after"));
1165 }
1166
1167 #[test]
1168 fn test_pandoc_code_block_after_div_open() {
1169 let rule = MD031BlanksAroundFences::default();
1172 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1173 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1174 let warnings = rule.check(&ctx).unwrap();
1175 assert!(
1176 warnings.is_empty(),
1177 "MD031 should not require blank line after Pandoc div opening: {warnings:?}"
1178 );
1179 }
1180}