1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::blank_lines::is_blank_or_comment_only;
7use crate::utils::calculate_indentation_width_default;
8use crate::utils::mkdocs_admonitions;
9use crate::utils::mkdocs_attr_list::is_block_attribute_line;
10use crate::utils::pandoc;
11use crate::utils::range_utils::calculate_line_range;
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 {
60 let line = lines.get(line_idx).unwrap_or(&"");
61
62 if is_blank_or_comment_only(line) {
64 return true;
65 }
66
67 if let Some(line_info) = ctx.lines.get(line_idx)
69 && let Some(ref bq) = line_info.blockquote
70 {
71 return bq.content.trim().is_empty();
73 }
74
75 false
76 }
77
78 fn is_in_list(&self, line_index: usize, lines: &[&str]) -> bool {
80 for i in (0..=line_index).rev() {
82 let line = lines[i];
83 let trimmed = line.trim_start();
84
85 if trimmed.is_empty() {
87 return false;
88 }
89
90 if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
92 let mut chars = trimmed.chars().skip_while(char::is_ascii_digit);
93 if let Some(next) = chars.next()
94 && (next == '.' || next == ')')
95 && chars.next() == Some(' ')
96 {
97 return true;
98 }
99 }
100
101 if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
103 return true;
104 }
105
106 let is_indented = calculate_indentation_width_default(line) >= 3;
108 if is_indented {
109 continue; }
111
112 return false;
115 }
116
117 false
118 }
119
120 fn should_require_blank_line(&self, line_index: usize, lines: &[&str]) -> bool {
122 if self.config.list_items {
123 true
125 } else {
126 !self.is_in_list(line_index, lines)
128 }
129 }
130
131 fn is_right_after_frontmatter(line_index: usize, ctx: &crate::lint_context::LintContext) -> bool {
133 line_index > 0
134 && ctx.lines.get(line_index - 1).is_some_and(|info| info.in_front_matter)
135 && ctx.lines.get(line_index).is_some_and(|info| !info.in_front_matter)
136 }
137
138 fn fenced_block_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
143 let lines = ctx.raw_lines();
144
145 ctx.code_block_details
146 .iter()
147 .filter(|d| d.is_fenced)
148 .map(|detail| {
149 let start_line = ctx
151 .line_offsets
152 .partition_point(|&off| off <= detail.start)
153 .saturating_sub(1);
154
155 let end_byte = if detail.end > 0 { detail.end - 1 } else { 0 };
157 let end_line = ctx
158 .line_offsets
159 .partition_point(|&off| off <= end_byte)
160 .saturating_sub(1);
161
162 let end_line_content = lines.get(end_line).unwrap_or(&"");
164 let trimmed = end_line_content.trim();
165 let content_after_bq = if trimmed.starts_with('>') {
166 trimmed.trim_start_matches(['>', ' ']).trim()
167 } else {
168 trimmed
169 };
170 let is_closing_fence = (content_after_bq.starts_with("```") || content_after_bq.starts_with("~~~"))
171 && content_after_bq
172 .chars()
173 .skip_while(|&c| c == '`' || c == '~')
174 .all(char::is_whitespace);
175
176 if is_closing_fence {
177 (start_line, end_line)
178 } else {
179 (start_line, lines.len().saturating_sub(1))
180 }
181 })
182 .collect()
183 }
184
185 fn colon_fence_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
187 ctx.colon_fence_details()
188 .iter()
189 .map(|fence| {
190 let (start, end) = (fence.start, fence.end);
191 let start_line = ctx.line_offsets.partition_point(|&off| off <= start).saturating_sub(1);
192 let end_byte = if end > 0 { end - 1 } else { 0 };
193 let end_line = ctx
194 .line_offsets
195 .partition_point(|&off| off <= end_byte)
196 .saturating_sub(1);
197 (start_line, end_line)
198 })
199 .collect()
200 }
201
202 fn myst_directive_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
204 ctx.myst_directive_ranges()
205 .iter()
206 .map(|&(start, end)| {
207 let start_line = ctx.line_offsets.partition_point(|&off| off <= start).saturating_sub(1);
208 let end_byte = if end > 0 { end - 1 } else { 0 };
209 let end_line = ctx
210 .line_offsets
211 .partition_point(|&off| off <= end_byte)
212 .saturating_sub(1);
213 (start_line, end_line)
214 })
215 .collect()
216 }
217}
218
219impl Rule for MD031BlanksAroundFences {
220 fn name(&self) -> &'static str {
221 "MD031"
222 }
223
224 fn description(&self) -> &'static str {
225 "Fenced code blocks should be surrounded by blank lines"
226 }
227
228 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
229 let mut warnings = Vec::new();
230 let lines = ctx.raw_lines();
231 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
232 let is_pandoc = ctx.flavor.is_pandoc_compatible();
233
234 let fenced_blocks = Self::fenced_block_line_ranges(ctx);
236
237 let is_pandoc_div_marker =
239 |line: &str| -> bool { is_pandoc && (pandoc::is_div_open(line) || pandoc::is_div_close(line)) };
240
241 for (opening_line, closing_line) in &fenced_blocks {
243 if ctx.line_info(*opening_line + 1).is_some_and(|info| {
245 info.in_html_comment
246 || info.in_mdx_comment
247 || info.in_front_matter
248 || info.in_mkdocstrings
249 || info.in_jsx_block
250 || info.in_kramdown_extension_block
251 || info.in_pymdown_block
252 }) {
253 continue;
254 }
255
256 let prev_line_is_pandoc_marker = *opening_line > 0 && is_pandoc_div_marker(lines[*opening_line - 1]);
261 if *opening_line > 0
262 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
263 && !Self::is_right_after_frontmatter(*opening_line, ctx)
264 && !prev_line_is_pandoc_marker
265 && self.should_require_blank_line(*opening_line, lines)
266 {
267 let (start_line, start_col, end_line, end_col) =
268 calculate_line_range(*opening_line + 1, lines[*opening_line]);
269
270 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
271 warnings.push(LintWarning {
272 rule_name: Some(self.name().to_string()),
273 line: start_line,
274 column: start_col,
275 end_line,
276 end_column: end_col,
277 message: "No blank line before fenced code block".to_string(),
278 severity: Severity::Warning,
279 fix: Some(Fix::new(
280 ctx.line_column_byte_range_with_length(*opening_line + 1, 1, 0),
281 format!("{bq_prefix}\n"),
282 )),
283 });
284 }
285
286 let next_line_is_pandoc_marker =
292 *closing_line + 1 < lines.len() && is_pandoc_div_marker(lines[*closing_line + 1]);
293 if *closing_line + 1 < lines.len()
294 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
295 && !is_block_attribute_line(lines[*closing_line + 1], ctx.flavor)
296 && !next_line_is_pandoc_marker
297 && self.should_require_blank_line(*closing_line, lines)
298 {
299 let (start_line, start_col, end_line, end_col) =
300 calculate_line_range(*closing_line + 1, lines[*closing_line]);
301
302 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
303 warnings.push(LintWarning {
304 rule_name: Some(self.name().to_string()),
305 line: start_line,
306 column: start_col,
307 end_line,
308 end_column: end_col,
309 message: "No blank line after fenced code block".to_string(),
310 severity: Severity::Warning,
311 fix: Some(Fix::new(
312 ctx.line_column_byte_range_with_length(*closing_line + 2, 1, 0),
313 format!("{bq_prefix}\n"),
314 )),
315 });
316 }
317 }
318
319 if ctx.flavor.supports_colon_code_fences() {
321 let colon_blocks = Self::colon_fence_line_ranges(ctx);
322 for (opening_line, closing_line) in &colon_blocks {
323 if *opening_line > 0
325 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
326 && !Self::is_right_after_frontmatter(*opening_line, ctx)
327 && self.should_require_blank_line(*opening_line, lines)
328 {
329 let (start_line, start_col, end_line, end_col) =
330 calculate_line_range(*opening_line + 1, lines[*opening_line]);
331 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
332 warnings.push(LintWarning {
333 rule_name: Some(self.name().to_string()),
334 line: start_line,
335 column: start_col,
336 end_line,
337 end_column: end_col,
338 message: "No blank line before colon code fence".to_string(),
339 severity: Severity::Warning,
340 fix: Some(Fix::new(
341 ctx.line_column_byte_range_with_length(*opening_line + 1, 1, 0),
342 format!("{bq_prefix}\n"),
343 )),
344 });
345 }
346
347 if *closing_line + 1 < lines.len()
349 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
350 && self.should_require_blank_line(*closing_line, lines)
351 {
352 let (start_line, start_col, end_line, end_col) =
353 calculate_line_range(*closing_line + 1, lines[*closing_line]);
354 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
355 warnings.push(LintWarning {
356 rule_name: Some(self.name().to_string()),
357 line: start_line,
358 column: start_col,
359 end_line,
360 end_column: end_col,
361 message: "No blank line after colon code fence".to_string(),
362 severity: Severity::Warning,
363 fix: Some(Fix::new(
364 ctx.line_column_byte_range_with_length(*closing_line + 2, 1, 0),
365 format!("{bq_prefix}\n"),
366 )),
367 });
368 }
369 }
370 }
371
372 if ctx.flavor.supports_myst_directives() {
374 let myst_blocks = Self::myst_directive_line_ranges(ctx);
375 for (opening_line, closing_line) in &myst_blocks {
376 if *opening_line > 0
378 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
379 && !Self::is_right_after_frontmatter(*opening_line, ctx)
380 && self.should_require_blank_line(*opening_line, lines)
381 {
382 let (start_line, start_col, end_line, end_col) =
383 calculate_line_range(*opening_line + 1, lines[*opening_line]);
384 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
385 warnings.push(LintWarning {
386 rule_name: Some(self.name().to_string()),
387 line: start_line,
388 column: start_col,
389 end_line,
390 end_column: end_col,
391 message: "No blank line before MyST directive".to_string(),
392 severity: Severity::Warning,
393 fix: Some(Fix::new(
394 ctx.line_column_byte_range_with_length(*opening_line + 1, 1, 0),
395 format!("{bq_prefix}\n"),
396 )),
397 });
398 }
399
400 if *closing_line + 1 < lines.len()
402 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
403 && self.should_require_blank_line(*closing_line, lines)
404 {
405 let (start_line, start_col, end_line, end_col) =
406 calculate_line_range(*closing_line + 1, lines[*closing_line]);
407 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
408 warnings.push(LintWarning {
409 rule_name: Some(self.name().to_string()),
410 line: start_line,
411 column: start_col,
412 end_line,
413 end_column: end_col,
414 message: "No blank line after MyST directive".to_string(),
415 severity: Severity::Warning,
416 fix: Some(Fix::new(
417 ctx.line_column_byte_range_with_length(*closing_line + 2, 1, 0),
418 format!("{bq_prefix}\n"),
419 )),
420 });
421 }
422 }
423 }
424
425 if is_mkdocs {
427 let mut in_admonition = false;
428 let mut admonition_indent = 0;
429 let mut i = 0;
430
431 while i < lines.len() {
432 let line = lines[i];
433
434 let in_fenced_block = fenced_blocks.iter().any(|(start, end)| i >= *start && i <= *end);
436 if in_fenced_block {
437 i += 1;
438 continue;
439 }
440
441 if ctx.line_info(i + 1).is_some_and(|info| info.in_pymdown_block) {
443 i += 1;
444 continue;
445 }
446
447 if mkdocs_admonitions::is_admonition_start(line) {
449 if i > 0
451 && !Self::is_effectively_empty_line(i - 1, lines, ctx)
452 && !Self::is_right_after_frontmatter(i, ctx)
453 && self.should_require_blank_line(i, lines)
454 {
455 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
456
457 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
458 warnings.push(LintWarning {
459 rule_name: Some(self.name().to_string()),
460 line: start_line,
461 column: start_col,
462 end_line,
463 end_column: end_col,
464 message: "No blank line before admonition block".to_string(),
465 severity: Severity::Warning,
466 fix: Some(Fix::new(
467 ctx.line_column_byte_range_with_length(i + 1, 1, 0),
468 format!("{bq_prefix}\n"),
469 )),
470 });
471 }
472
473 in_admonition = true;
474 admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
475 i += 1;
476 continue;
477 }
478
479 if in_admonition
481 && !line.trim().is_empty()
482 && !mkdocs_admonitions::is_admonition_content(line, admonition_indent)
483 {
484 in_admonition = false;
485
486 if i > 0
490 && !Self::is_effectively_empty_line(i - 1, lines, ctx)
491 && self.should_require_blank_line(i - 1, lines)
492 {
493 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
494
495 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
496 warnings.push(LintWarning {
497 rule_name: Some(self.name().to_string()),
498 line: start_line,
499 column: start_col,
500 end_line,
501 end_column: end_col,
502 message: "No blank line after admonition block".to_string(),
503 severity: Severity::Warning,
504 fix: Some(Fix::new(
505 ctx.line_column_byte_range_with_length(i + 1, 1, 0),
506 format!("{bq_prefix}\n"),
507 )),
508 });
509 }
510
511 admonition_indent = 0;
512 }
513
514 i += 1;
515 }
516 }
517
518 Ok(warnings)
519 }
520
521 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
522 if self.should_skip(ctx) {
523 return Ok(ctx.content.to_string());
524 }
525 let warnings = self.check(ctx)?;
526 if warnings.is_empty() {
527 return Ok(ctx.content.to_string());
528 }
529 let warnings =
530 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
531 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
532 .map_err(crate::rule::LintError::InvalidInput)
533 }
534
535 fn category(&self) -> RuleCategory {
537 RuleCategory::CodeBlock
538 }
539
540 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
542 if ctx.content.is_empty() {
543 return true;
544 }
545 let has_fences = ctx.likely_has_code() || ctx.has_char('~');
546 let has_mkdocs_admonitions = ctx.flavor == crate::config::MarkdownFlavor::MkDocs && ctx.content.contains("!!!");
547 let has_colon_fences = ctx.flavor.supports_colon_code_fences() && ctx.content.contains(":::");
548 let has_myst_directives = ctx.flavor.supports_myst_directives() && ctx.content.contains(":::");
549 !has_fences && !has_mkdocs_admonitions && !has_colon_fences && !has_myst_directives
550 }
551
552 fn as_any(&self) -> &dyn std::any::Any {
553 self
554 }
555
556 crate::impl_rule_config_methods!(MD031Config);
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use crate::lint_context::LintContext;
563
564 #[test]
565 fn test_basic_functionality() {
566 let rule = MD031BlanksAroundFences::default();
567
568 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
571 let warnings = rule.check(&ctx).unwrap();
572 assert!(
573 warnings.is_empty(),
574 "Expected no warnings for properly formatted code blocks"
575 );
576
577 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
580 let warnings = rule.check(&ctx).unwrap();
581 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
582 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
583 assert!(
584 warnings[0].message.contains("before"),
585 "Warning should be about blank line before"
586 );
587
588 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591 let warnings = rule.check(&ctx).unwrap();
592 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
593 assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
594 assert!(
595 warnings[0].message.contains("after"),
596 "Warning should be about blank line after"
597 );
598
599 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
602 let warnings = rule.check(&ctx).unwrap();
603 assert_eq!(
604 warnings.len(),
605 2,
606 "Expected 2 warnings for missing blank lines before and after"
607 );
608 }
609
610 #[test]
611 fn test_nested_code_blocks() {
612 let rule = MD031BlanksAroundFences::default();
613
614 let content = r#"````markdown
616```
617content
618```
619````"#;
620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621 let warnings = rule.check(&ctx).unwrap();
622 assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
623
624 let fixed = rule.fix(&ctx).unwrap();
626 assert_eq!(fixed, content, "Fix should not modify nested code blocks");
627 }
628
629 #[test]
630 fn test_nested_code_blocks_complex() {
631 let rule = MD031BlanksAroundFences::default();
632
633 let content = r#"# Documentation
635
636## Examples
637
638````markdown
639```python
640def hello():
641 print("Hello, world!")
642```
643
644```javascript
645console.log("Hello, world!");
646```
647````
648
649More text here."#;
650
651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652 let warnings = rule.check(&ctx).unwrap();
653 assert_eq!(
654 warnings.len(),
655 0,
656 "Should not flag any issues in properly formatted nested code blocks"
657 );
658
659 let content_5 = r#"`````markdown
661````python
662```bash
663echo "nested"
664```
665````
666`````"#;
667
668 let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
669 let warnings_5 = rule.check(&ctx_5).unwrap();
670 assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
671 }
672
673 #[test]
674 fn test_fix_preserves_trailing_newline() {
675 let rule = MD031BlanksAroundFences::default();
676
677 let content = "Some text\n```\ncode\n```\nMore text\n";
679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680 let fixed = rule.fix(&ctx).unwrap();
681
682 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
684 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
685 }
686
687 #[test]
688 fn test_fix_preserves_no_trailing_newline() {
689 let rule = MD031BlanksAroundFences::default();
690
691 let content = "Some text\n```\ncode\n```\nMore text";
693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694 let fixed = rule.fix(&ctx).unwrap();
695
696 assert!(
698 !fixed.ends_with('\n'),
699 "Fix should not add trailing newline if original didn't have one"
700 );
701 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
702 }
703
704 #[test]
705 fn test_list_items_config_true() {
706 let rule = MD031BlanksAroundFences::new(true);
708
709 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
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_list_items_config_false() {
721 let rule = MD031BlanksAroundFences::new(false);
723
724 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726 let warnings = rule.check(&ctx).unwrap();
727
728 assert_eq!(warnings.len(), 0);
730 }
731
732 #[test]
733 fn test_list_items_config_false_outside_list() {
734 let rule = MD031BlanksAroundFences::new(false);
736
737 let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739 let warnings = rule.check(&ctx).unwrap();
740
741 assert_eq!(warnings.len(), 2);
743 assert!(warnings[0].message.contains("before"));
744 assert!(warnings[1].message.contains("after"));
745 }
746
747 #[test]
748 fn test_default_config_section() {
749 let rule = MD031BlanksAroundFences::default();
750 let config_section = rule.default_config_section();
751
752 assert!(config_section.is_some());
753 let (name, value) = config_section.unwrap();
754 assert_eq!(name, "MD031");
755
756 if let toml::Value::Table(table) = value {
758 assert!(table.contains_key("list-items"));
759 assert_eq!(table["list-items"], toml::Value::Boolean(true));
760 } else {
761 panic!("Expected TOML table");
762 }
763 }
764
765 #[test]
766 fn test_fix_list_items_config_false() {
767 let rule = MD031BlanksAroundFences::new(false);
769
770 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
772 let fixed = rule.fix(&ctx).unwrap();
773
774 assert_eq!(fixed, content);
776 }
777
778 #[test]
779 fn test_fix_list_items_config_true() {
780 let rule = MD031BlanksAroundFences::new(true);
782
783 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785 let fixed = rule.fix(&ctx).unwrap();
786
787 let expected = "1. First item\n\n ```python\n code()\n ```\n\n2. Second item";
789 assert_eq!(fixed, expected);
790 }
791
792 #[test]
793 fn test_no_warning_after_frontmatter() {
794 let rule = MD031BlanksAroundFences::default();
797
798 let content = "---\ntitle: Test\n---\n```\ncode\n```";
799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800 let warnings = rule.check(&ctx).unwrap();
801
802 assert!(
804 warnings.is_empty(),
805 "Expected no warnings for code block after frontmatter, got: {warnings:?}"
806 );
807 }
808
809 #[test]
810 fn test_fix_does_not_add_blank_after_frontmatter() {
811 let rule = MD031BlanksAroundFences::default();
813
814 let content = "---\ntitle: Test\n---\n```\ncode\n```";
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816 let fixed = rule.fix(&ctx).unwrap();
817
818 assert_eq!(fixed, content);
820 }
821
822 #[test]
823 fn test_frontmatter_with_blank_line_before_code() {
824 let rule = MD031BlanksAroundFences::default();
826
827 let content = "---\ntitle: Test\n---\n\n```\ncode\n```";
828 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
829 let warnings = rule.check(&ctx).unwrap();
830
831 assert!(warnings.is_empty());
832 }
833
834 #[test]
835 fn test_no_warning_for_admonition_after_frontmatter() {
836 let rule = MD031BlanksAroundFences::default();
838
839 let content = "---\ntitle: Test\n---\n!!! note\n This is a note";
840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
841 let warnings = rule.check(&ctx).unwrap();
842
843 assert!(
844 warnings.is_empty(),
845 "Expected no warnings for admonition after frontmatter, got: {warnings:?}"
846 );
847 }
848
849 #[test]
850 fn test_toml_frontmatter_before_code() {
851 let rule = MD031BlanksAroundFences::default();
853
854 let content = "+++\ntitle = \"Test\"\n+++\n```\ncode\n```";
855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856 let warnings = rule.check(&ctx).unwrap();
857
858 assert!(
859 warnings.is_empty(),
860 "Expected no warnings for code block after TOML frontmatter, got: {warnings:?}"
861 );
862 }
863
864 #[test]
865 fn test_fenced_code_in_list_with_4_space_indent_issue_276() {
866 let rule = MD031BlanksAroundFences::new(true);
870
871 let content =
873 "1. First item\n2. Second item with code:\n ```python\n print(\"Hello\")\n ```\n3. Third item";
874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875 let warnings = rule.check(&ctx).unwrap();
876
877 assert_eq!(
879 warnings.len(),
880 2,
881 "Should detect fenced code in list with 4-space indent, got: {warnings:?}"
882 );
883 assert!(warnings[0].message.contains("before"));
884 assert!(warnings[1].message.contains("after"));
885
886 let fixed = rule.fix(&ctx).unwrap();
888 let expected =
889 "1. First item\n2. Second item with code:\n\n ```python\n print(\"Hello\")\n ```\n\n3. Third item";
890 assert_eq!(
891 fixed, expected,
892 "Fix should add blank lines around list-indented fenced code"
893 );
894 }
895
896 #[test]
897 fn test_fenced_code_in_list_with_mixed_indentation() {
898 let rule = MD031BlanksAroundFences::new(true);
900
901 let content = r#"# Test
902
9033-space indent:
9041. First item
905 ```python
906 code
907 ```
9082. Second item
909
9104-space indent:
9111. First item
912 ```python
913 code
914 ```
9152. Second item"#;
916
917 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
918 let warnings = rule.check(&ctx).unwrap();
919
920 assert_eq!(
922 warnings.len(),
923 4,
924 "Should detect all fenced code blocks regardless of indentation, got: {warnings:?}"
925 );
926 }
927
928 #[test]
929 fn test_fix_preserves_blockquote_prefix_before_fence() {
930 let rule = MD031BlanksAroundFences::default();
932
933 let content = "> Text before
934> ```
935> code
936> ```";
937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938 let fixed = rule.fix(&ctx).unwrap();
939
940 let expected = "> Text before
942>
943> ```
944> code
945> ```";
946 assert_eq!(
947 fixed, expected,
948 "Fix should insert '>' blank line, not plain blank line"
949 );
950 }
951
952 #[test]
953 fn test_fix_preserves_blockquote_prefix_after_fence() {
954 let rule = MD031BlanksAroundFences::default();
956
957 let content = "> ```
958> code
959> ```
960> Text after";
961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
962 let fixed = rule.fix(&ctx).unwrap();
963
964 let expected = "> ```
966> code
967> ```
968>
969> Text after";
970 assert_eq!(
971 fixed, expected,
972 "Fix should insert '>' blank line after fence, not plain blank line"
973 );
974 }
975
976 #[test]
977 fn test_fix_preserves_nested_blockquote_prefix() {
978 let rule = MD031BlanksAroundFences::default();
980
981 let content = ">> Nested quote
982>> ```
983>> code
984>> ```
985>> More text";
986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
987 let fixed = rule.fix(&ctx).unwrap();
988
989 let expected = ">> Nested quote
991>>
992>> ```
993>> code
994>> ```
995>>
996>> More text";
997 assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
998 }
999
1000 #[test]
1001 fn test_fix_preserves_triple_nested_blockquote_prefix() {
1002 let rule = MD031BlanksAroundFences::default();
1004
1005 let content = ">>> Triple nested
1006>>> ```
1007>>> code
1008>>> ```
1009>>> More text";
1010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011 let fixed = rule.fix(&ctx).unwrap();
1012
1013 let expected = ">>> Triple nested
1014>>>
1015>>> ```
1016>>> code
1017>>> ```
1018>>>
1019>>> More text";
1020 assert_eq!(
1021 fixed, expected,
1022 "Fix should preserve triple-nested blockquote prefix '>>>'"
1023 );
1024 }
1025
1026 #[test]
1029 fn test_quarto_code_block_after_div_open() {
1030 let rule = MD031BlanksAroundFences::default();
1032 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1034 let warnings = rule.check(&ctx).unwrap();
1035 assert!(
1036 warnings.is_empty(),
1037 "Should not require blank line after Quarto div opening: {warnings:?}"
1038 );
1039 }
1040
1041 #[test]
1042 fn test_quarto_code_block_before_div_close() {
1043 let rule = MD031BlanksAroundFences::default();
1045 let content = "::: {.callout-note}\nSome text\n```python\ncode\n```\n:::";
1046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1047 let warnings = rule.check(&ctx).unwrap();
1048 assert!(
1050 warnings.len() <= 1,
1051 "Should not require blank line before Quarto div closing: {warnings:?}"
1052 );
1053 }
1054
1055 #[test]
1056 fn test_quarto_code_block_outside_div_still_requires_blanks() {
1057 let rule = MD031BlanksAroundFences::default();
1059 let content = "Some text\n```python\ncode\n```\nMore text";
1060 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1061 let warnings = rule.check(&ctx).unwrap();
1062 assert_eq!(
1063 warnings.len(),
1064 2,
1065 "Should still require blank lines around code blocks outside divs"
1066 );
1067 }
1068
1069 #[test]
1070 fn test_quarto_code_block_with_callout_note() {
1071 let rule = MD031BlanksAroundFences::default();
1073 let content = "::: {.callout-note}\n```r\n1 + 1\n```\n:::\n\nMore text";
1074 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1075 let warnings = rule.check(&ctx).unwrap();
1076 assert!(
1077 warnings.is_empty(),
1078 "Callout note with code block should have no warnings: {warnings:?}"
1079 );
1080 }
1081
1082 #[test]
1083 fn test_quarto_nested_divs_with_code() {
1084 let rule = MD031BlanksAroundFences::default();
1086 let content = "::: {.outer}\n::: {.inner}\n```python\ncode\n```\n:::\n:::\n";
1087 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1088 let warnings = rule.check(&ctx).unwrap();
1089 assert!(
1090 warnings.is_empty(),
1091 "Nested divs with code blocks should have no warnings: {warnings:?}"
1092 );
1093 }
1094
1095 #[test]
1096 fn test_quarto_div_markers_in_standard_flavor() {
1097 let rule = MD031BlanksAroundFences::default();
1099 let content = "::: {.callout-note}\n```python\ncode\n```\n:::\n";
1100 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1101 let warnings = rule.check(&ctx).unwrap();
1102 assert!(
1105 !warnings.is_empty(),
1106 "Standard flavor should require blanks around code blocks: {warnings:?}"
1107 );
1108 }
1109
1110 #[test]
1111 fn test_quarto_fix_does_not_add_blanks_at_div_boundaries() {
1112 let rule = MD031BlanksAroundFences::default();
1114 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1116 let fixed = rule.fix(&ctx).unwrap();
1117 assert_eq!(fixed, content, "Fix should not add blanks at Quarto div boundaries");
1119 }
1120
1121 #[test]
1122 fn test_quarto_code_block_with_content_before() {
1123 let rule = MD031BlanksAroundFences::default();
1125 let content = "::: {.callout-note}\nHere is some code:\n```python\ncode\n```\n:::";
1126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1127 let warnings = rule.check(&ctx).unwrap();
1128 assert_eq!(
1130 warnings.len(),
1131 1,
1132 "Should require blank before code block inside div: {warnings:?}"
1133 );
1134 assert!(warnings[0].message.contains("before"));
1135 }
1136
1137 #[test]
1138 fn test_quarto_code_block_with_content_after() {
1139 let rule = MD031BlanksAroundFences::default();
1141 let content = "::: {.callout-note}\n```python\ncode\n```\nMore content here.\n:::";
1142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1143 let warnings = rule.check(&ctx).unwrap();
1144 assert_eq!(
1146 warnings.len(),
1147 1,
1148 "Should require blank after code block inside div: {warnings:?}"
1149 );
1150 assert!(warnings[0].message.contains("after"));
1151 }
1152
1153 #[test]
1154 fn test_pandoc_code_block_after_div_open() {
1155 let rule = MD031BlanksAroundFences::default();
1158 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1160 let warnings = rule.check(&ctx).unwrap();
1161 assert!(
1162 warnings.is_empty(),
1163 "MD031 should not require blank line after Pandoc div opening: {warnings:?}"
1164 );
1165 }
1166
1167 #[test]
1168 fn test_md031_html_comment() {
1169 let rule = MD031BlanksAroundFences::default();
1170 let content = "text <!--\n```rust\nconst x = 1;\n```\n-->";
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1172 let warnings = rule.check(&ctx).unwrap();
1173 assert!(
1174 warnings.is_empty(),
1175 "MD031 should not require blank lines around fenced blocks inside HTML comments: {warnings:?}"
1176 );
1177 }
1178
1179 #[test]
1180 fn test_md031_block_attribute_after_fence_not_flagged() {
1181 let rule = MD031BlanksAroundFences::default();
1184 let content = "Some text.\n\n```rust\nlet x = 1;\n```\n{class=\"highlight\"}\n\nMore text.";
1185
1186 for flavor in [
1187 crate::config::MarkdownFlavor::Hugo,
1188 crate::config::MarkdownFlavor::MkDocs,
1189 crate::config::MarkdownFlavor::Kramdown,
1190 ] {
1191 let ctx = LintContext::new(content, flavor, None);
1192 let warnings = rule.check(&ctx).unwrap();
1193 assert!(
1194 warnings.is_empty(),
1195 "MD031 should not flag the block attribute line under {flavor:?}: {warnings:?}"
1196 );
1197 }
1198
1199 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1202 let warnings_std = rule.check(&ctx_std).unwrap();
1203 assert!(
1204 warnings_std
1205 .iter()
1206 .any(|w| w.message == "No blank line after fenced code block"),
1207 "MD031 must flag the missing blank after the fence under Standard: {warnings_std:?}"
1208 );
1209 }
1210}