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::mkdocs_admonitions;
8use crate::utils::mkdocs_attr_list::is_block_attribute_line;
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_details()
186 .iter()
187 .map(|fence| {
188 let (start, end) = (fence.start, fence.end);
189 let start_line = ctx.line_offsets.partition_point(|&off| off <= start).saturating_sub(1);
190 let end_byte = if end > 0 { end - 1 } else { 0 };
191 let end_line = ctx
192 .line_offsets
193 .partition_point(|&off| off <= end_byte)
194 .saturating_sub(1);
195 (start_line, end_line)
196 })
197 .collect()
198 }
199
200 fn myst_directive_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
202 ctx.myst_directive_ranges()
203 .iter()
204 .map(|&(start, end)| {
205 let start_line = ctx.line_offsets.partition_point(|&off| off <= start).saturating_sub(1);
206 let end_byte = if end > 0 { end - 1 } else { 0 };
207 let end_line = ctx
208 .line_offsets
209 .partition_point(|&off| off <= end_byte)
210 .saturating_sub(1);
211 (start_line, end_line)
212 })
213 .collect()
214 }
215}
216
217impl Rule for MD031BlanksAroundFences {
218 fn name(&self) -> &'static str {
219 "MD031"
220 }
221
222 fn description(&self) -> &'static str {
223 "Fenced code blocks should be surrounded by blank lines"
224 }
225
226 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
227 let mut warnings = Vec::new();
228 let lines = ctx.raw_lines();
229 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
230 let is_pandoc = ctx.flavor.is_pandoc_compatible();
231
232 let fenced_blocks = Self::fenced_block_line_ranges(ctx);
234
235 let is_pandoc_div_marker =
237 |line: &str| -> bool { is_pandoc && (pandoc::is_div_open(line) || pandoc::is_div_close(line)) };
238
239 for (opening_line, closing_line) in &fenced_blocks {
241 if ctx.line_info(*opening_line + 1).is_some_and(|info| {
243 info.in_html_comment
244 || info.in_mdx_comment
245 || info.in_front_matter
246 || info.in_mkdocstrings
247 || info.in_jsx_block
248 || info.in_kramdown_extension_block
249 || info.in_pymdown_block
250 }) {
251 continue;
252 }
253
254 let prev_line_is_pandoc_marker = *opening_line > 0 && is_pandoc_div_marker(lines[*opening_line - 1]);
259 if *opening_line > 0
260 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
261 && !Self::is_right_after_frontmatter(*opening_line, ctx)
262 && !prev_line_is_pandoc_marker
263 && self.should_require_blank_line(*opening_line, lines)
264 {
265 let (start_line, start_col, end_line, end_col) =
266 calculate_line_range(*opening_line + 1, lines[*opening_line]);
267
268 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
269 warnings.push(LintWarning {
270 rule_name: Some(self.name().to_string()),
271 line: start_line,
272 column: start_col,
273 end_line,
274 end_column: end_col,
275 message: "No blank line before fenced code block".to_string(),
276 severity: Severity::Warning,
277 fix: Some(Fix::new(
278 ctx.line_column_byte_range_with_length(*opening_line + 1, 1, 0),
279 format!("{bq_prefix}\n"),
280 )),
281 });
282 }
283
284 let next_line_is_pandoc_marker =
290 *closing_line + 1 < lines.len() && is_pandoc_div_marker(lines[*closing_line + 1]);
291 if *closing_line + 1 < lines.len()
292 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
293 && !is_block_attribute_line(lines[*closing_line + 1], ctx.flavor)
294 && !next_line_is_pandoc_marker
295 && self.should_require_blank_line(*closing_line, lines)
296 {
297 let (start_line, start_col, end_line, end_col) =
298 calculate_line_range(*closing_line + 1, lines[*closing_line]);
299
300 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
301 warnings.push(LintWarning {
302 rule_name: Some(self.name().to_string()),
303 line: start_line,
304 column: start_col,
305 end_line,
306 end_column: end_col,
307 message: "No blank line after fenced code block".to_string(),
308 severity: Severity::Warning,
309 fix: Some(Fix::new(
310 ctx.line_column_byte_range_with_length(*closing_line + 2, 1, 0),
311 format!("{bq_prefix}\n"),
312 )),
313 });
314 }
315 }
316
317 if ctx.flavor.supports_colon_code_fences() {
319 let colon_blocks = Self::colon_fence_line_ranges(ctx);
320 for (opening_line, closing_line) in &colon_blocks {
321 if *opening_line > 0
323 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
324 && !Self::is_right_after_frontmatter(*opening_line, ctx)
325 && self.should_require_blank_line(*opening_line, lines)
326 {
327 let (start_line, start_col, end_line, end_col) =
328 calculate_line_range(*opening_line + 1, lines[*opening_line]);
329 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
330 warnings.push(LintWarning {
331 rule_name: Some(self.name().to_string()),
332 line: start_line,
333 column: start_col,
334 end_line,
335 end_column: end_col,
336 message: "No blank line before colon code fence".to_string(),
337 severity: Severity::Warning,
338 fix: Some(Fix::new(
339 ctx.line_column_byte_range_with_length(*opening_line + 1, 1, 0),
340 format!("{bq_prefix}\n"),
341 )),
342 });
343 }
344
345 if *closing_line + 1 < lines.len()
347 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
348 && self.should_require_blank_line(*closing_line, lines)
349 {
350 let (start_line, start_col, end_line, end_col) =
351 calculate_line_range(*closing_line + 1, lines[*closing_line]);
352 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
353 warnings.push(LintWarning {
354 rule_name: Some(self.name().to_string()),
355 line: start_line,
356 column: start_col,
357 end_line,
358 end_column: end_col,
359 message: "No blank line after colon code fence".to_string(),
360 severity: Severity::Warning,
361 fix: Some(Fix::new(
362 ctx.line_column_byte_range_with_length(*closing_line + 2, 1, 0),
363 format!("{bq_prefix}\n"),
364 )),
365 });
366 }
367 }
368 }
369
370 if ctx.flavor.supports_myst_directives() {
372 let myst_blocks = Self::myst_directive_line_ranges(ctx);
373 for (opening_line, closing_line) in &myst_blocks {
374 if *opening_line > 0
376 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
377 && !Self::is_right_after_frontmatter(*opening_line, ctx)
378 && self.should_require_blank_line(*opening_line, lines)
379 {
380 let (start_line, start_col, end_line, end_col) =
381 calculate_line_range(*opening_line + 1, lines[*opening_line]);
382 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
383 warnings.push(LintWarning {
384 rule_name: Some(self.name().to_string()),
385 line: start_line,
386 column: start_col,
387 end_line,
388 end_column: end_col,
389 message: "No blank line before MyST directive".to_string(),
390 severity: Severity::Warning,
391 fix: Some(Fix::new(
392 ctx.line_column_byte_range_with_length(*opening_line + 1, 1, 0),
393 format!("{bq_prefix}\n"),
394 )),
395 });
396 }
397
398 if *closing_line + 1 < lines.len()
400 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
401 && self.should_require_blank_line(*closing_line, lines)
402 {
403 let (start_line, start_col, end_line, end_col) =
404 calculate_line_range(*closing_line + 1, lines[*closing_line]);
405 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
406 warnings.push(LintWarning {
407 rule_name: Some(self.name().to_string()),
408 line: start_line,
409 column: start_col,
410 end_line,
411 end_column: end_col,
412 message: "No blank line after MyST directive".to_string(),
413 severity: Severity::Warning,
414 fix: Some(Fix::new(
415 ctx.line_column_byte_range_with_length(*closing_line + 2, 1, 0),
416 format!("{bq_prefix}\n"),
417 )),
418 });
419 }
420 }
421 }
422
423 if is_mkdocs {
425 let mut in_admonition = false;
426 let mut admonition_indent = 0;
427 let mut i = 0;
428
429 while i < lines.len() {
430 let line = lines[i];
431
432 let in_fenced_block = fenced_blocks.iter().any(|(start, end)| i >= *start && i <= *end);
434 if in_fenced_block {
435 i += 1;
436 continue;
437 }
438
439 if ctx.line_info(i + 1).is_some_and(|info| info.in_pymdown_block) {
441 i += 1;
442 continue;
443 }
444
445 if mkdocs_admonitions::is_admonition_start(line) {
447 if i > 0
449 && !Self::is_effectively_empty_line(i - 1, lines, ctx)
450 && !Self::is_right_after_frontmatter(i, ctx)
451 && self.should_require_blank_line(i, lines)
452 {
453 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
454
455 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
456 warnings.push(LintWarning {
457 rule_name: Some(self.name().to_string()),
458 line: start_line,
459 column: start_col,
460 end_line,
461 end_column: end_col,
462 message: "No blank line before admonition block".to_string(),
463 severity: Severity::Warning,
464 fix: Some(Fix::new(
465 ctx.line_column_byte_range_with_length(i + 1, 1, 0),
466 format!("{bq_prefix}\n"),
467 )),
468 });
469 }
470
471 in_admonition = true;
472 admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
473 i += 1;
474 continue;
475 }
476
477 if in_admonition
479 && !line.trim().is_empty()
480 && !mkdocs_admonitions::is_admonition_content(line, admonition_indent)
481 {
482 in_admonition = false;
483
484 if i > 0
488 && !Self::is_effectively_empty_line(i - 1, lines, ctx)
489 && self.should_require_blank_line(i - 1, lines)
490 {
491 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
492
493 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
494 warnings.push(LintWarning {
495 rule_name: Some(self.name().to_string()),
496 line: start_line,
497 column: start_col,
498 end_line,
499 end_column: end_col,
500 message: "No blank line after admonition block".to_string(),
501 severity: Severity::Warning,
502 fix: Some(Fix::new(
503 ctx.line_column_byte_range_with_length(i + 1, 1, 0),
504 format!("{bq_prefix}\n"),
505 )),
506 });
507 }
508
509 admonition_indent = 0;
510 }
511
512 i += 1;
513 }
514 }
515
516 Ok(warnings)
517 }
518
519 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
520 if self.should_skip(ctx) {
521 return Ok(ctx.content.to_string());
522 }
523 let warnings = self.check(ctx)?;
524 if warnings.is_empty() {
525 return Ok(ctx.content.to_string());
526 }
527 let warnings =
528 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
529 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
530 .map_err(crate::rule::LintError::InvalidInput)
531 }
532
533 fn category(&self) -> RuleCategory {
535 RuleCategory::CodeBlock
536 }
537
538 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
540 if ctx.content.is_empty() {
541 return true;
542 }
543 let has_fences = ctx.likely_has_code() || ctx.has_char('~');
544 let has_mkdocs_admonitions = ctx.flavor == crate::config::MarkdownFlavor::MkDocs && ctx.content.contains("!!!");
545 let has_colon_fences = ctx.flavor.supports_colon_code_fences() && ctx.content.contains(":::");
546 let has_myst_directives = ctx.flavor.supports_myst_directives() && ctx.content.contains(":::");
547 !has_fences && !has_mkdocs_admonitions && !has_colon_fences && !has_myst_directives
548 }
549
550 fn as_any(&self) -> &dyn std::any::Any {
551 self
552 }
553
554 crate::impl_rule_config_methods!(MD031Config);
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560 use crate::lint_context::LintContext;
561
562 #[test]
563 fn test_basic_functionality() {
564 let rule = MD031BlanksAroundFences::default();
565
566 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569 let warnings = rule.check(&ctx).unwrap();
570 assert!(
571 warnings.is_empty(),
572 "Expected no warnings for properly formatted code blocks"
573 );
574
575 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578 let warnings = rule.check(&ctx).unwrap();
579 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
580 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
581 assert!(
582 warnings[0].message.contains("before"),
583 "Warning should be about blank line before"
584 );
585
586 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
589 let warnings = rule.check(&ctx).unwrap();
590 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
591 assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
592 assert!(
593 warnings[0].message.contains("after"),
594 "Warning should be about blank line after"
595 );
596
597 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600 let warnings = rule.check(&ctx).unwrap();
601 assert_eq!(
602 warnings.len(),
603 2,
604 "Expected 2 warnings for missing blank lines before and after"
605 );
606 }
607
608 #[test]
609 fn test_nested_code_blocks() {
610 let rule = MD031BlanksAroundFences::default();
611
612 let content = r#"````markdown
614```
615content
616```
617````"#;
618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
619 let warnings = rule.check(&ctx).unwrap();
620 assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
621
622 let fixed = rule.fix(&ctx).unwrap();
624 assert_eq!(fixed, content, "Fix should not modify nested code blocks");
625 }
626
627 #[test]
628 fn test_nested_code_blocks_complex() {
629 let rule = MD031BlanksAroundFences::default();
630
631 let content = r#"# Documentation
633
634## Examples
635
636````markdown
637```python
638def hello():
639 print("Hello, world!")
640```
641
642```javascript
643console.log("Hello, world!");
644```
645````
646
647More text here."#;
648
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650 let warnings = rule.check(&ctx).unwrap();
651 assert_eq!(
652 warnings.len(),
653 0,
654 "Should not flag any issues in properly formatted nested code blocks"
655 );
656
657 let content_5 = r#"`````markdown
659````python
660```bash
661echo "nested"
662```
663````
664`````"#;
665
666 let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
667 let warnings_5 = rule.check(&ctx_5).unwrap();
668 assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
669 }
670
671 #[test]
672 fn test_fix_preserves_trailing_newline() {
673 let rule = MD031BlanksAroundFences::default();
674
675 let content = "Some text\n```\ncode\n```\nMore text\n";
677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
678 let fixed = rule.fix(&ctx).unwrap();
679
680 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
682 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
683 }
684
685 #[test]
686 fn test_fix_preserves_no_trailing_newline() {
687 let rule = MD031BlanksAroundFences::default();
688
689 let content = "Some text\n```\ncode\n```\nMore text";
691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692 let fixed = rule.fix(&ctx).unwrap();
693
694 assert!(
696 !fixed.ends_with('\n'),
697 "Fix should not add trailing newline if original didn't have one"
698 );
699 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
700 }
701
702 #[test]
703 fn test_list_items_config_true() {
704 let rule = MD031BlanksAroundFences::new(true);
706
707 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
709 let warnings = rule.check(&ctx).unwrap();
710
711 assert_eq!(warnings.len(), 2);
713 assert!(warnings[0].message.contains("before"));
714 assert!(warnings[1].message.contains("after"));
715 }
716
717 #[test]
718 fn test_list_items_config_false() {
719 let rule = MD031BlanksAroundFences::new(false);
721
722 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
724 let warnings = rule.check(&ctx).unwrap();
725
726 assert_eq!(warnings.len(), 0);
728 }
729
730 #[test]
731 fn test_list_items_config_false_outside_list() {
732 let rule = MD031BlanksAroundFences::new(false);
734
735 let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737 let warnings = rule.check(&ctx).unwrap();
738
739 assert_eq!(warnings.len(), 2);
741 assert!(warnings[0].message.contains("before"));
742 assert!(warnings[1].message.contains("after"));
743 }
744
745 #[test]
746 fn test_default_config_section() {
747 let rule = MD031BlanksAroundFences::default();
748 let config_section = rule.default_config_section();
749
750 assert!(config_section.is_some());
751 let (name, value) = config_section.unwrap();
752 assert_eq!(name, "MD031");
753
754 if let toml::Value::Table(table) = value {
756 assert!(table.contains_key("list-items"));
757 assert_eq!(table["list-items"], toml::Value::Boolean(true));
758 } else {
759 panic!("Expected TOML table");
760 }
761 }
762
763 #[test]
764 fn test_fix_list_items_config_false() {
765 let rule = MD031BlanksAroundFences::new(false);
767
768 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let fixed = rule.fix(&ctx).unwrap();
771
772 assert_eq!(fixed, content);
774 }
775
776 #[test]
777 fn test_fix_list_items_config_true() {
778 let rule = MD031BlanksAroundFences::new(true);
780
781 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let fixed = rule.fix(&ctx).unwrap();
784
785 let expected = "1. First item\n\n ```python\n code()\n ```\n\n2. Second item";
787 assert_eq!(fixed, expected);
788 }
789
790 #[test]
791 fn test_no_warning_after_frontmatter() {
792 let rule = MD031BlanksAroundFences::default();
795
796 let content = "---\ntitle: Test\n---\n```\ncode\n```";
797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
798 let warnings = rule.check(&ctx).unwrap();
799
800 assert!(
802 warnings.is_empty(),
803 "Expected no warnings for code block after frontmatter, got: {warnings:?}"
804 );
805 }
806
807 #[test]
808 fn test_fix_does_not_add_blank_after_frontmatter() {
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 fixed = rule.fix(&ctx).unwrap();
815
816 assert_eq!(fixed, content);
818 }
819
820 #[test]
821 fn test_frontmatter_with_blank_line_before_code() {
822 let rule = MD031BlanksAroundFences::default();
824
825 let content = "---\ntitle: Test\n---\n\n```\ncode\n```";
826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
827 let warnings = rule.check(&ctx).unwrap();
828
829 assert!(warnings.is_empty());
830 }
831
832 #[test]
833 fn test_no_warning_for_admonition_after_frontmatter() {
834 let rule = MD031BlanksAroundFences::default();
836
837 let content = "---\ntitle: Test\n---\n!!! note\n This is a note";
838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
839 let warnings = rule.check(&ctx).unwrap();
840
841 assert!(
842 warnings.is_empty(),
843 "Expected no warnings for admonition after frontmatter, got: {warnings:?}"
844 );
845 }
846
847 #[test]
848 fn test_toml_frontmatter_before_code() {
849 let rule = MD031BlanksAroundFences::default();
851
852 let content = "+++\ntitle = \"Test\"\n+++\n```\ncode\n```";
853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
854 let warnings = rule.check(&ctx).unwrap();
855
856 assert!(
857 warnings.is_empty(),
858 "Expected no warnings for code block after TOML frontmatter, got: {warnings:?}"
859 );
860 }
861
862 #[test]
863 fn test_fenced_code_in_list_with_4_space_indent_issue_276() {
864 let rule = MD031BlanksAroundFences::new(true);
868
869 let content =
871 "1. First item\n2. Second item with code:\n ```python\n print(\"Hello\")\n ```\n3. Third item";
872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
873 let warnings = rule.check(&ctx).unwrap();
874
875 assert_eq!(
877 warnings.len(),
878 2,
879 "Should detect fenced code in list with 4-space indent, got: {warnings:?}"
880 );
881 assert!(warnings[0].message.contains("before"));
882 assert!(warnings[1].message.contains("after"));
883
884 let fixed = rule.fix(&ctx).unwrap();
886 let expected =
887 "1. First item\n2. Second item with code:\n\n ```python\n print(\"Hello\")\n ```\n\n3. Third item";
888 assert_eq!(
889 fixed, expected,
890 "Fix should add blank lines around list-indented fenced code"
891 );
892 }
893
894 #[test]
895 fn test_fenced_code_in_list_with_mixed_indentation() {
896 let rule = MD031BlanksAroundFences::new(true);
898
899 let content = r#"# Test
900
9013-space indent:
9021. First item
903 ```python
904 code
905 ```
9062. Second item
907
9084-space indent:
9091. First item
910 ```python
911 code
912 ```
9132. Second item"#;
914
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916 let warnings = rule.check(&ctx).unwrap();
917
918 assert_eq!(
920 warnings.len(),
921 4,
922 "Should detect all fenced code blocks regardless of indentation, got: {warnings:?}"
923 );
924 }
925
926 #[test]
927 fn test_fix_preserves_blockquote_prefix_before_fence() {
928 let rule = MD031BlanksAroundFences::default();
930
931 let content = "> Text before
932> ```
933> code
934> ```";
935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936 let fixed = rule.fix(&ctx).unwrap();
937
938 let expected = "> Text before
940>
941> ```
942> code
943> ```";
944 assert_eq!(
945 fixed, expected,
946 "Fix should insert '>' blank line, not plain blank line"
947 );
948 }
949
950 #[test]
951 fn test_fix_preserves_blockquote_prefix_after_fence() {
952 let rule = MD031BlanksAroundFences::default();
954
955 let content = "> ```
956> code
957> ```
958> Text after";
959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
960 let fixed = rule.fix(&ctx).unwrap();
961
962 let expected = "> ```
964> code
965> ```
966>
967> Text after";
968 assert_eq!(
969 fixed, expected,
970 "Fix should insert '>' blank line after fence, not plain blank line"
971 );
972 }
973
974 #[test]
975 fn test_fix_preserves_nested_blockquote_prefix() {
976 let rule = MD031BlanksAroundFences::default();
978
979 let content = ">> Nested quote
980>> ```
981>> code
982>> ```
983>> More text";
984 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
985 let fixed = rule.fix(&ctx).unwrap();
986
987 let expected = ">> Nested quote
989>>
990>> ```
991>> code
992>> ```
993>>
994>> More text";
995 assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
996 }
997
998 #[test]
999 fn test_fix_preserves_triple_nested_blockquote_prefix() {
1000 let rule = MD031BlanksAroundFences::default();
1002
1003 let content = ">>> Triple nested
1004>>> ```
1005>>> code
1006>>> ```
1007>>> More text";
1008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1009 let fixed = rule.fix(&ctx).unwrap();
1010
1011 let expected = ">>> Triple nested
1012>>>
1013>>> ```
1014>>> code
1015>>> ```
1016>>>
1017>>> More text";
1018 assert_eq!(
1019 fixed, expected,
1020 "Fix should preserve triple-nested blockquote prefix '>>>'"
1021 );
1022 }
1023
1024 #[test]
1027 fn test_quarto_code_block_after_div_open() {
1028 let rule = MD031BlanksAroundFences::default();
1030 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1032 let warnings = rule.check(&ctx).unwrap();
1033 assert!(
1034 warnings.is_empty(),
1035 "Should not require blank line after Quarto div opening: {warnings:?}"
1036 );
1037 }
1038
1039 #[test]
1040 fn test_quarto_code_block_before_div_close() {
1041 let rule = MD031BlanksAroundFences::default();
1043 let content = "::: {.callout-note}\nSome text\n```python\ncode\n```\n:::";
1044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1045 let warnings = rule.check(&ctx).unwrap();
1046 assert!(
1048 warnings.len() <= 1,
1049 "Should not require blank line before Quarto div closing: {warnings:?}"
1050 );
1051 }
1052
1053 #[test]
1054 fn test_quarto_code_block_outside_div_still_requires_blanks() {
1055 let rule = MD031BlanksAroundFences::default();
1057 let content = "Some text\n```python\ncode\n```\nMore text";
1058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1059 let warnings = rule.check(&ctx).unwrap();
1060 assert_eq!(
1061 warnings.len(),
1062 2,
1063 "Should still require blank lines around code blocks outside divs"
1064 );
1065 }
1066
1067 #[test]
1068 fn test_quarto_code_block_with_callout_note() {
1069 let rule = MD031BlanksAroundFences::default();
1071 let content = "::: {.callout-note}\n```r\n1 + 1\n```\n:::\n\nMore text";
1072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1073 let warnings = rule.check(&ctx).unwrap();
1074 assert!(
1075 warnings.is_empty(),
1076 "Callout note with code block should have no warnings: {warnings:?}"
1077 );
1078 }
1079
1080 #[test]
1081 fn test_quarto_nested_divs_with_code() {
1082 let rule = MD031BlanksAroundFences::default();
1084 let content = "::: {.outer}\n::: {.inner}\n```python\ncode\n```\n:::\n:::\n";
1085 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1086 let warnings = rule.check(&ctx).unwrap();
1087 assert!(
1088 warnings.is_empty(),
1089 "Nested divs with code blocks should have no warnings: {warnings:?}"
1090 );
1091 }
1092
1093 #[test]
1094 fn test_quarto_div_markers_in_standard_flavor() {
1095 let rule = MD031BlanksAroundFences::default();
1097 let content = "::: {.callout-note}\n```python\ncode\n```\n:::\n";
1098 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1099 let warnings = rule.check(&ctx).unwrap();
1100 assert!(
1103 !warnings.is_empty(),
1104 "Standard flavor should require blanks around code blocks: {warnings:?}"
1105 );
1106 }
1107
1108 #[test]
1109 fn test_quarto_fix_does_not_add_blanks_at_div_boundaries() {
1110 let rule = MD031BlanksAroundFences::default();
1112 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1113 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1114 let fixed = rule.fix(&ctx).unwrap();
1115 assert_eq!(fixed, content, "Fix should not add blanks at Quarto div boundaries");
1117 }
1118
1119 #[test]
1120 fn test_quarto_code_block_with_content_before() {
1121 let rule = MD031BlanksAroundFences::default();
1123 let content = "::: {.callout-note}\nHere is some code:\n```python\ncode\n```\n:::";
1124 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1125 let warnings = rule.check(&ctx).unwrap();
1126 assert_eq!(
1128 warnings.len(),
1129 1,
1130 "Should require blank before code block inside div: {warnings:?}"
1131 );
1132 assert!(warnings[0].message.contains("before"));
1133 }
1134
1135 #[test]
1136 fn test_quarto_code_block_with_content_after() {
1137 let rule = MD031BlanksAroundFences::default();
1139 let content = "::: {.callout-note}\n```python\ncode\n```\nMore content here.\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 after code block inside div: {warnings:?}"
1147 );
1148 assert!(warnings[0].message.contains("after"));
1149 }
1150
1151 #[test]
1152 fn test_pandoc_code_block_after_div_open() {
1153 let rule = MD031BlanksAroundFences::default();
1156 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1158 let warnings = rule.check(&ctx).unwrap();
1159 assert!(
1160 warnings.is_empty(),
1161 "MD031 should not require blank line after Pandoc div opening: {warnings:?}"
1162 );
1163 }
1164
1165 #[test]
1166 fn test_md031_html_comment() {
1167 let rule = MD031BlanksAroundFences::default();
1168 let content = "text <!--\n```rust\nconst x = 1;\n```\n-->";
1169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170 let warnings = rule.check(&ctx).unwrap();
1171 assert!(
1172 warnings.is_empty(),
1173 "MD031 should not require blank lines around fenced blocks inside HTML comments: {warnings:?}"
1174 );
1175 }
1176
1177 #[test]
1178 fn test_md031_block_attribute_after_fence_not_flagged() {
1179 let rule = MD031BlanksAroundFences::default();
1182 let content = "Some text.\n\n```rust\nlet x = 1;\n```\n{class=\"highlight\"}\n\nMore text.";
1183
1184 for flavor in [
1185 crate::config::MarkdownFlavor::Hugo,
1186 crate::config::MarkdownFlavor::MkDocs,
1187 crate::config::MarkdownFlavor::Kramdown,
1188 ] {
1189 let ctx = LintContext::new(content, flavor, None);
1190 let warnings = rule.check(&ctx).unwrap();
1191 assert!(
1192 warnings.is_empty(),
1193 "MD031 should not flag the block attribute line under {flavor:?}: {warnings:?}"
1194 );
1195 }
1196
1197 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200 let warnings_std = rule.check(&ctx_std).unwrap();
1201 assert!(
1202 warnings_std
1203 .iter()
1204 .any(|w| w.message == "No blank line after fenced code block"),
1205 "MD031 must flag the missing blank after the fence under Standard: {warnings_std:?}"
1206 );
1207 }
1208}