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