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, bool)> {
145 let ambiguity_limit = super::code_fence_utils::fence_ambiguity_limit(ctx);
146
147 ctx.code_block_details
148 .iter()
149 .filter(|d| d.is_fenced && !ctx.is_inside_template_code(d.start) && !ctx.is_inside_mdx_code(d.start))
150 .filter(|d| ambiguity_limit.is_none_or(|limit| d.start < limit))
151 .map(|detail| {
152 let start_line = ctx
154 .line_offsets
155 .partition_point(|&off| off <= detail.start)
156 .saturating_sub(1);
157
158 let end_byte = if detail.end > 0 { detail.end - 1 } else { 0 };
160 let end_line = ctx
161 .line_offsets
162 .partition_point(|&off| off <= end_byte)
163 .saturating_sub(1);
164
165 (start_line, end_line, detail.closed)
166 })
167 .collect()
168 }
169
170 fn bare_item_content_lines(ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<usize> {
180 let content = ctx.content;
181 let line_of = |offset: usize| ctx.line_offsets.partition_point(|&off| off <= offset).saturating_sub(1);
182 let mut found = std::collections::HashSet::new();
183
184 let items = ctx
185 .parser_blocks()
186 .events
187 .iter()
188 .filter(|(kind, _)| *kind == crate::lint_context::document_parser::blocks::BlockKind::ItemStart)
189 .map(|(_, range)| range.clone());
190
191 for range in items {
192 let marker_line = line_of(range.start);
193 let Some(&next_line_start) = ctx.line_offsets.get(marker_line + 1) else {
194 continue;
195 };
196 let line_end = next_line_start.min(content.len());
197 let after_marker = content[range.start..line_end]
198 .trim_start_matches(|c: char| c.is_ascii_digit())
199 .get(1..)
200 .unwrap_or("");
201 if after_marker.trim().is_empty() && range.end > next_line_start {
202 found.insert(marker_line + 1);
203 }
204 }
205 found
206 }
207
208 fn begins_bare_item(
211 ctx: &crate::lint_context::LintContext,
212 cache: &std::cell::OnceCell<std::collections::HashSet<usize>>,
213 line_idx: usize,
214 ) -> bool {
215 let Some(above) = line_idx.checked_sub(1).and_then(|i| ctx.raw_lines().get(i).copied()) else {
216 return false;
217 };
218 above.trim_end().ends_with(['-', '+', '*', '.', ')'])
221 && cache
222 .get_or_init(|| Self::bare_item_content_lines(ctx))
223 .contains(&line_idx)
224 }
225
226 fn colon_fence_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
228 ctx.colon_fence_details()
229 .iter()
230 .map(|fence| {
231 let (start, end) = (fence.start, fence.end);
232 let start_line = ctx.line_offsets.partition_point(|&off| off <= start).saturating_sub(1);
233 let end_byte = if end > 0 { end - 1 } else { 0 };
234 let end_line = ctx
235 .line_offsets
236 .partition_point(|&off| off <= end_byte)
237 .saturating_sub(1);
238 (start_line, end_line)
239 })
240 .collect()
241 }
242
243 fn myst_directive_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
245 ctx.myst_directive_ranges()
246 .iter()
247 .map(|&(start, end)| {
248 let start_line = ctx.line_offsets.partition_point(|&off| off <= start).saturating_sub(1);
249 let end_byte = if end > 0 { end - 1 } else { 0 };
250 let end_line = ctx
251 .line_offsets
252 .partition_point(|&off| off <= end_byte)
253 .saturating_sub(1);
254 (start_line, end_line)
255 })
256 .collect()
257 }
258}
259
260impl Rule for MD031BlanksAroundFences {
261 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
262 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(31))
263 }
264
265 fn name(&self) -> &'static str {
266 "MD031"
267 }
268
269 fn description(&self) -> &'static str {
270 "Fenced code blocks should be surrounded by blank lines"
271 }
272
273 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
274 let mut warnings = Vec::new();
275 let lines = ctx.raw_lines();
276 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
277 let is_pandoc = ctx.flavor.is_pandoc_compatible();
278
279 let fenced_blocks = Self::fenced_block_line_ranges(ctx);
281 let bare_items = std::cell::OnceCell::new();
283
284 let is_pandoc_div_marker =
286 |line: &str| -> bool { is_pandoc && (pandoc::is_div_open(line) || pandoc::is_div_close(line)) };
287
288 for (opening_line, closing_line, closed) in &fenced_blocks {
290 if ctx.line_info(*opening_line + 1).is_some_and(|info| {
292 info.in_html_comment
293 || info.in_mdx_comment
294 || info.in_front_matter
295 || info.in_mkdocstrings
296 || info.in_jsx_block
297 || info.in_kramdown_extension_block
298 || info.in_pymdown_block
299 }) {
300 continue;
301 }
302
303 let prev_line_is_pandoc_marker = *opening_line > 0 && is_pandoc_div_marker(lines[*opening_line - 1]);
308 if *opening_line > 0
309 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
310 && !Self::is_right_after_frontmatter(*opening_line, ctx)
311 && !prev_line_is_pandoc_marker
312 && self.should_require_blank_line(*opening_line, lines)
313 && !Self::begins_bare_item(ctx, &bare_items, *opening_line)
314 {
315 let (start_line, start_col, end_line, end_col) =
316 calculate_line_range(*opening_line + 1, lines[*opening_line]);
317
318 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
319 warnings.push(LintWarning {
320 rule_name: Some(self.name().to_string()),
321 line: start_line,
322 column: start_col,
323 end_line,
324 end_column: end_col,
325 message: "No blank line before fenced code block".to_string(),
326 severity: Severity::Warning,
327 fix: Some(Fix::new(
328 ctx.line_column_byte_range_with_length(*opening_line + 1, 1, 0),
329 format!("{bq_prefix}\n"),
330 )),
331 });
332 }
333
334 let next_line_is_pandoc_marker =
340 *closing_line + 1 < lines.len() && is_pandoc_div_marker(lines[*closing_line + 1]);
341 if *closed
342 && *closing_line + 1 < lines.len()
343 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
344 && !is_block_attribute_line(lines[*closing_line + 1], ctx.flavor)
345 && !next_line_is_pandoc_marker
346 && self.should_require_blank_line(*closing_line, lines)
347 {
348 let (start_line, start_col, end_line, end_col) =
349 calculate_line_range(*closing_line + 1, lines[*closing_line]);
350
351 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
352 warnings.push(LintWarning {
353 rule_name: Some(self.name().to_string()),
354 line: start_line,
355 column: start_col,
356 end_line,
357 end_column: end_col,
358 message: "No blank line after fenced code block".to_string(),
359 severity: Severity::Warning,
360 fix: Some(Fix::new(
361 ctx.line_column_byte_range_with_length(*closing_line + 2, 1, 0),
362 format!("{bq_prefix}\n"),
363 )),
364 });
365 }
366 }
367
368 if ctx.flavor.supports_colon_code_fences() {
370 let colon_blocks = Self::colon_fence_line_ranges(ctx);
371 for (opening_line, closing_line) in &colon_blocks {
372 if *opening_line > 0
374 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
375 && !Self::is_right_after_frontmatter(*opening_line, ctx)
376 && self.should_require_blank_line(*opening_line, lines)
377 && !Self::begins_bare_item(ctx, &bare_items, *opening_line)
378 {
379 let (start_line, start_col, end_line, end_col) =
380 calculate_line_range(*opening_line + 1, lines[*opening_line]);
381 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
382 warnings.push(LintWarning {
383 rule_name: Some(self.name().to_string()),
384 line: start_line,
385 column: start_col,
386 end_line,
387 end_column: end_col,
388 message: "No blank line before colon code fence".to_string(),
389 severity: Severity::Warning,
390 fix: Some(Fix::new(
391 ctx.line_column_byte_range_with_length(*opening_line + 1, 1, 0),
392 format!("{bq_prefix}\n"),
393 )),
394 });
395 }
396
397 if *closing_line + 1 < lines.len()
399 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
400 && self.should_require_blank_line(*closing_line, lines)
401 {
402 let (start_line, start_col, end_line, end_col) =
403 calculate_line_range(*closing_line + 1, lines[*closing_line]);
404 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
405 warnings.push(LintWarning {
406 rule_name: Some(self.name().to_string()),
407 line: start_line,
408 column: start_col,
409 end_line,
410 end_column: end_col,
411 message: "No blank line after colon code fence".to_string(),
412 severity: Severity::Warning,
413 fix: Some(Fix::new(
414 ctx.line_column_byte_range_with_length(*closing_line + 2, 1, 0),
415 format!("{bq_prefix}\n"),
416 )),
417 });
418 }
419 }
420 }
421
422 if ctx.flavor.supports_myst_directives() {
424 let myst_blocks = Self::myst_directive_line_ranges(ctx);
425 for (opening_line, closing_line) in &myst_blocks {
426 if *opening_line > 0
428 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
429 && !Self::is_right_after_frontmatter(*opening_line, ctx)
430 && self.should_require_blank_line(*opening_line, lines)
431 && !Self::begins_bare_item(ctx, &bare_items, *opening_line)
432 {
433 let (start_line, start_col, end_line, end_col) =
434 calculate_line_range(*opening_line + 1, lines[*opening_line]);
435 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
436 warnings.push(LintWarning {
437 rule_name: Some(self.name().to_string()),
438 line: start_line,
439 column: start_col,
440 end_line,
441 end_column: end_col,
442 message: "No blank line before MyST directive".to_string(),
443 severity: Severity::Warning,
444 fix: Some(Fix::new(
445 ctx.line_column_byte_range_with_length(*opening_line + 1, 1, 0),
446 format!("{bq_prefix}\n"),
447 )),
448 });
449 }
450
451 if *closing_line + 1 < lines.len()
453 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
454 && self.should_require_blank_line(*closing_line, lines)
455 {
456 let (start_line, start_col, end_line, end_col) =
457 calculate_line_range(*closing_line + 1, lines[*closing_line]);
458 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
459 warnings.push(LintWarning {
460 rule_name: Some(self.name().to_string()),
461 line: start_line,
462 column: start_col,
463 end_line,
464 end_column: end_col,
465 message: "No blank line after MyST directive".to_string(),
466 severity: Severity::Warning,
467 fix: Some(Fix::new(
468 ctx.line_column_byte_range_with_length(*closing_line + 2, 1, 0),
469 format!("{bq_prefix}\n"),
470 )),
471 });
472 }
473 }
474 }
475
476 if is_mkdocs {
478 let mut in_admonition = false;
479 let mut admonition_indent = 0;
480 let mut i = 0;
481
482 while i < lines.len() {
483 let line = lines[i];
484
485 let in_fenced_block = fenced_blocks.iter().any(|(start, end, _)| i >= *start && i <= *end);
487 if in_fenced_block {
488 i += 1;
489 continue;
490 }
491
492 if ctx.line_info(i + 1).is_some_and(|info| info.in_pymdown_block) {
494 i += 1;
495 continue;
496 }
497
498 if mkdocs_admonitions::is_admonition_start(line) {
500 if i > 0
502 && !Self::is_effectively_empty_line(i - 1, lines, ctx)
503 && !Self::is_right_after_frontmatter(i, ctx)
504 && self.should_require_blank_line(i, lines)
505 && !Self::begins_bare_item(ctx, &bare_items, i)
506 {
507 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
508
509 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
510 warnings.push(LintWarning {
511 rule_name: Some(self.name().to_string()),
512 line: start_line,
513 column: start_col,
514 end_line,
515 end_column: end_col,
516 message: "No blank line before admonition block".to_string(),
517 severity: Severity::Warning,
518 fix: Some(Fix::new(
519 ctx.line_column_byte_range_with_length(i + 1, 1, 0),
520 format!("{bq_prefix}\n"),
521 )),
522 });
523 }
524
525 in_admonition = true;
526 admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
527 i += 1;
528 continue;
529 }
530
531 if in_admonition
533 && !line.trim().is_empty()
534 && !mkdocs_admonitions::is_admonition_content(line, admonition_indent)
535 {
536 in_admonition = false;
537
538 if i > 0
542 && !Self::is_effectively_empty_line(i - 1, lines, ctx)
543 && self.should_require_blank_line(i - 1, lines)
544 {
545 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
546
547 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
548 warnings.push(LintWarning {
549 rule_name: Some(self.name().to_string()),
550 line: start_line,
551 column: start_col,
552 end_line,
553 end_column: end_col,
554 message: "No blank line after admonition block".to_string(),
555 severity: Severity::Warning,
556 fix: Some(Fix::new(
557 ctx.line_column_byte_range_with_length(i + 1, 1, 0),
558 format!("{bq_prefix}\n"),
559 )),
560 });
561 }
562
563 admonition_indent = 0;
564 }
565
566 i += 1;
567 }
568 }
569
570 Ok(warnings)
571 }
572
573 fn fixes_from_diagnostics(&self) -> bool {
574 true
575 }
576
577 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
578 if self.should_skip(ctx) {
579 return Ok(ctx.content.to_string());
580 }
581 let warnings = self.check(ctx)?;
582 if warnings.is_empty() {
583 return Ok(ctx.content.to_string());
584 }
585 let warnings =
586 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
587 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
588 .map_err(crate::rule::LintError::InvalidInput)
589 }
590
591 fn category(&self) -> RuleCategory {
593 RuleCategory::CodeBlock
594 }
595
596 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
598 if ctx.content.is_empty() {
599 return true;
600 }
601 let has_fences = ctx.likely_has_code() || ctx.has_char('~');
602 let has_mkdocs_admonitions = ctx.flavor == crate::config::MarkdownFlavor::MkDocs && ctx.content.contains("!!!");
603 let has_colon_fences = ctx.flavor.supports_colon_code_fences() && ctx.content.contains(":::");
604 let has_myst_directives = ctx.flavor.supports_myst_directives() && ctx.content.contains(":::");
605 !has_fences && !has_mkdocs_admonitions && !has_colon_fences && !has_myst_directives
606 }
607
608 fn as_any(&self) -> &dyn std::any::Any {
609 self
610 }
611
612 crate::impl_rule_config_methods!(MD031Config);
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618 use crate::lint_context::LintContext;
619
620 #[test]
621 fn test_basic_functionality() {
622 let rule = MD031BlanksAroundFences::default();
623
624 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
627 let warnings = rule.check(&ctx).unwrap();
628 assert!(
629 warnings.is_empty(),
630 "Expected no warnings for properly formatted code blocks"
631 );
632
633 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636 let warnings = rule.check(&ctx).unwrap();
637 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
638 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
639 assert!(
640 warnings[0].message.contains("before"),
641 "Warning should be about blank line before"
642 );
643
644 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
647 let warnings = rule.check(&ctx).unwrap();
648 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
649 assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
650 assert!(
651 warnings[0].message.contains("after"),
652 "Warning should be about blank line after"
653 );
654
655 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
658 let warnings = rule.check(&ctx).unwrap();
659 assert_eq!(
660 warnings.len(),
661 2,
662 "Expected 2 warnings for missing blank lines before and after"
663 );
664 }
665
666 #[test]
667 fn test_nested_code_blocks() {
668 let rule = MD031BlanksAroundFences::default();
669
670 let content = r#"````markdown
672```
673content
674```
675````"#;
676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677 let warnings = rule.check(&ctx).unwrap();
678 assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
679
680 let fixed = rule.fix(&ctx).unwrap();
682 assert_eq!(fixed, content, "Fix should not modify nested code blocks");
683 }
684
685 #[test]
686 fn test_nested_code_blocks_complex() {
687 let rule = MD031BlanksAroundFences::default();
688
689 let content = r#"# Documentation
691
692## Examples
693
694````markdown
695```python
696def hello():
697 print("Hello, world!")
698```
699
700```javascript
701console.log("Hello, world!");
702```
703````
704
705More text here."#;
706
707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
708 let warnings = rule.check(&ctx).unwrap();
709 assert_eq!(
710 warnings.len(),
711 0,
712 "Should not flag any issues in properly formatted nested code blocks"
713 );
714
715 let content_5 = r#"`````markdown
717````python
718```bash
719echo "nested"
720```
721````
722`````"#;
723
724 let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
725 let warnings_5 = rule.check(&ctx_5).unwrap();
726 assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
727 }
728
729 #[test]
730 fn test_fix_preserves_trailing_newline() {
731 let rule = MD031BlanksAroundFences::default();
732
733 let content = "Some text\n```\ncode\n```\nMore text\n";
735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
736 let fixed = rule.fix(&ctx).unwrap();
737
738 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
740 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
741 }
742
743 #[test]
744 fn test_fix_preserves_no_trailing_newline() {
745 let rule = MD031BlanksAroundFences::default();
746
747 let content = "Some text\n```\ncode\n```\nMore text";
749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750 let fixed = rule.fix(&ctx).unwrap();
751
752 assert!(
754 !fixed.ends_with('\n'),
755 "Fix should not add trailing newline if original didn't have one"
756 );
757 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
758 }
759
760 #[test]
761 fn test_list_items_config_true() {
762 let rule = MD031BlanksAroundFences::new(true);
764
765 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
767 let warnings = rule.check(&ctx).unwrap();
768
769 assert_eq!(warnings.len(), 2);
771 assert!(warnings[0].message.contains("before"));
772 assert!(warnings[1].message.contains("after"));
773 }
774
775 #[test]
776 fn test_list_items_config_false() {
777 let rule = MD031BlanksAroundFences::new(false);
779
780 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
782 let warnings = rule.check(&ctx).unwrap();
783
784 assert_eq!(warnings.len(), 0);
786 }
787
788 #[test]
789 fn test_list_items_config_false_outside_list() {
790 let rule = MD031BlanksAroundFences::new(false);
792
793 let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
795 let warnings = rule.check(&ctx).unwrap();
796
797 assert_eq!(warnings.len(), 2);
799 assert!(warnings[0].message.contains("before"));
800 assert!(warnings[1].message.contains("after"));
801 }
802
803 #[test]
804 fn test_default_config_section() {
805 let rule = MD031BlanksAroundFences::default();
806 let config_section = rule.default_config_section();
807
808 assert!(config_section.is_some());
809 let (name, value) = config_section.unwrap();
810 assert_eq!(name, "MD031");
811
812 if let toml::Value::Table(table) = value {
814 assert!(table.contains_key("list-items"));
815 assert_eq!(table["list-items"], toml::Value::Boolean(true));
816 } else {
817 panic!("Expected TOML table");
818 }
819 }
820
821 #[test]
822 fn test_fix_list_items_config_false() {
823 let rule = MD031BlanksAroundFences::new(false);
825
826 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let fixed = rule.fix(&ctx).unwrap();
829
830 assert_eq!(fixed, content);
832 }
833
834 #[test]
835 fn test_fix_list_items_config_true() {
836 let rule = MD031BlanksAroundFences::new(true);
838
839 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
841 let fixed = rule.fix(&ctx).unwrap();
842
843 let expected = "1. First item\n\n ```python\n code()\n ```\n\n2. Second item";
845 assert_eq!(fixed, expected);
846 }
847
848 #[test]
849 fn test_no_warning_after_frontmatter() {
850 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!(
860 warnings.is_empty(),
861 "Expected no warnings for code block after frontmatter, got: {warnings:?}"
862 );
863 }
864
865 #[test]
866 fn test_fix_does_not_add_blank_after_frontmatter() {
867 let rule = MD031BlanksAroundFences::default();
869
870 let content = "---\ntitle: Test\n---\n```\ncode\n```";
871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
872 let fixed = rule.fix(&ctx).unwrap();
873
874 assert_eq!(fixed, content);
876 }
877
878 #[test]
879 fn test_frontmatter_with_blank_line_before_code() {
880 let rule = MD031BlanksAroundFences::default();
882
883 let content = "---\ntitle: Test\n---\n\n```\ncode\n```";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885 let warnings = rule.check(&ctx).unwrap();
886
887 assert!(warnings.is_empty());
888 }
889
890 #[test]
891 fn test_no_warning_for_admonition_after_frontmatter() {
892 let rule = MD031BlanksAroundFences::default();
894
895 let content = "---\ntitle: Test\n---\n!!! note\n This is a note";
896 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
897 let warnings = rule.check(&ctx).unwrap();
898
899 assert!(
900 warnings.is_empty(),
901 "Expected no warnings for admonition after frontmatter, got: {warnings:?}"
902 );
903 }
904
905 #[test]
906 fn test_toml_frontmatter_before_code() {
907 let rule = MD031BlanksAroundFences::default();
909
910 let content = "+++\ntitle = \"Test\"\n+++\n```\ncode\n```";
911 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
912 let warnings = rule.check(&ctx).unwrap();
913
914 assert!(
915 warnings.is_empty(),
916 "Expected no warnings for code block after TOML frontmatter, got: {warnings:?}"
917 );
918 }
919
920 #[test]
921 fn test_fenced_code_in_list_with_4_space_indent_issue_276() {
922 let rule = MD031BlanksAroundFences::new(true);
926
927 let content =
929 "1. First item\n2. Second item with code:\n ```python\n print(\"Hello\")\n ```\n3. Third item";
930 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
931 let warnings = rule.check(&ctx).unwrap();
932
933 assert_eq!(
935 warnings.len(),
936 2,
937 "Should detect fenced code in list with 4-space indent, got: {warnings:?}"
938 );
939 assert!(warnings[0].message.contains("before"));
940 assert!(warnings[1].message.contains("after"));
941
942 let fixed = rule.fix(&ctx).unwrap();
944 let expected =
945 "1. First item\n2. Second item with code:\n\n ```python\n print(\"Hello\")\n ```\n\n3. Third item";
946 assert_eq!(
947 fixed, expected,
948 "Fix should add blank lines around list-indented fenced code"
949 );
950 }
951
952 #[test]
953 fn test_fenced_code_in_list_with_mixed_indentation() {
954 let rule = MD031BlanksAroundFences::new(true);
956
957 let content = r#"# Test
958
9593-space indent:
9601. First item
961 ```python
962 code
963 ```
9642. Second item
965
9664-space indent:
9671. First item
968 ```python
969 code
970 ```
9712. Second item"#;
972
973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974 let warnings = rule.check(&ctx).unwrap();
975
976 assert_eq!(
978 warnings.len(),
979 4,
980 "Should detect all fenced code blocks regardless of indentation, got: {warnings:?}"
981 );
982 }
983
984 #[test]
985 fn test_fix_preserves_blockquote_prefix_before_fence() {
986 let rule = MD031BlanksAroundFences::default();
988
989 let content = "> Text before
990> ```
991> code
992> ```";
993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
994 let fixed = rule.fix(&ctx).unwrap();
995
996 let expected = "> Text before
998>
999> ```
1000> code
1001> ```";
1002 assert_eq!(
1003 fixed, expected,
1004 "Fix should insert '>' blank line, not plain blank line"
1005 );
1006 }
1007
1008 #[test]
1009 fn test_fix_preserves_blockquote_prefix_after_fence() {
1010 let rule = MD031BlanksAroundFences::default();
1012
1013 let content = "> ```
1014> code
1015> ```
1016> Text after";
1017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1018 let fixed = rule.fix(&ctx).unwrap();
1019
1020 let expected = "> ```
1022> code
1023> ```
1024>
1025> Text after";
1026 assert_eq!(
1027 fixed, expected,
1028 "Fix should insert '>' blank line after fence, not plain blank line"
1029 );
1030 }
1031
1032 #[test]
1033 fn test_fix_preserves_nested_blockquote_prefix() {
1034 let rule = MD031BlanksAroundFences::default();
1036
1037 let content = ">> Nested quote
1038>> ```
1039>> code
1040>> ```
1041>> More text";
1042 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1043 let fixed = rule.fix(&ctx).unwrap();
1044
1045 let expected = ">> Nested quote
1047>>
1048>> ```
1049>> code
1050>> ```
1051>>
1052>> More text";
1053 assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
1054 }
1055
1056 #[test]
1057 fn test_fix_preserves_triple_nested_blockquote_prefix() {
1058 let rule = MD031BlanksAroundFences::default();
1060
1061 let content = ">>> Triple nested
1062>>> ```
1063>>> code
1064>>> ```
1065>>> More text";
1066 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1067 let fixed = rule.fix(&ctx).unwrap();
1068
1069 let expected = ">>> Triple nested
1070>>>
1071>>> ```
1072>>> code
1073>>> ```
1074>>>
1075>>> More text";
1076 assert_eq!(
1077 fixed, expected,
1078 "Fix should preserve triple-nested blockquote prefix '>>>'"
1079 );
1080 }
1081
1082 #[test]
1085 fn test_quarto_code_block_after_div_open() {
1086 let rule = MD031BlanksAroundFences::default();
1088 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1090 let warnings = rule.check(&ctx).unwrap();
1091 assert!(
1092 warnings.is_empty(),
1093 "Should not require blank line after Quarto div opening: {warnings:?}"
1094 );
1095 }
1096
1097 #[test]
1098 fn test_quarto_code_block_before_div_close() {
1099 let rule = MD031BlanksAroundFences::default();
1101 let content = "::: {.callout-note}\nSome text\n```python\ncode\n```\n:::";
1102 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1103 let warnings = rule.check(&ctx).unwrap();
1104 assert!(
1106 warnings.len() <= 1,
1107 "Should not require blank line before Quarto div closing: {warnings:?}"
1108 );
1109 }
1110
1111 #[test]
1112 fn test_quarto_code_block_outside_div_still_requires_blanks() {
1113 let rule = MD031BlanksAroundFences::default();
1115 let content = "Some text\n```python\ncode\n```\nMore text";
1116 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1117 let warnings = rule.check(&ctx).unwrap();
1118 assert_eq!(
1119 warnings.len(),
1120 2,
1121 "Should still require blank lines around code blocks outside divs"
1122 );
1123 }
1124
1125 #[test]
1126 fn test_quarto_code_block_with_callout_note() {
1127 let rule = MD031BlanksAroundFences::default();
1129 let content = "::: {.callout-note}\n```r\n1 + 1\n```\n:::\n\nMore text";
1130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1131 let warnings = rule.check(&ctx).unwrap();
1132 assert!(
1133 warnings.is_empty(),
1134 "Callout note with code block should have no warnings: {warnings:?}"
1135 );
1136 }
1137
1138 #[test]
1139 fn test_quarto_nested_divs_with_code() {
1140 let rule = MD031BlanksAroundFences::default();
1142 let content = "::: {.outer}\n::: {.inner}\n```python\ncode\n```\n:::\n:::\n";
1143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1144 let warnings = rule.check(&ctx).unwrap();
1145 assert!(
1146 warnings.is_empty(),
1147 "Nested divs with code blocks should have no warnings: {warnings:?}"
1148 );
1149 }
1150
1151 #[test]
1152 fn test_quarto_div_markers_in_standard_flavor() {
1153 let rule = MD031BlanksAroundFences::default();
1155 let content = "::: {.callout-note}\n```python\ncode\n```\n:::\n";
1156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1157 let warnings = rule.check(&ctx).unwrap();
1158 assert!(
1161 !warnings.is_empty(),
1162 "Standard flavor should require blanks around code blocks: {warnings:?}"
1163 );
1164 }
1165
1166 #[test]
1167 fn test_quarto_fix_does_not_add_blanks_at_div_boundaries() {
1168 let rule = MD031BlanksAroundFences::default();
1170 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1172 let fixed = rule.fix(&ctx).unwrap();
1173 assert_eq!(fixed, content, "Fix should not add blanks at Quarto div boundaries");
1175 }
1176
1177 #[test]
1178 fn test_quarto_code_block_with_content_before() {
1179 let rule = MD031BlanksAroundFences::default();
1181 let content = "::: {.callout-note}\nHere is some code:\n```python\ncode\n```\n:::";
1182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1183 let warnings = rule.check(&ctx).unwrap();
1184 assert_eq!(
1186 warnings.len(),
1187 1,
1188 "Should require blank before code block inside div: {warnings:?}"
1189 );
1190 assert!(warnings[0].message.contains("before"));
1191 }
1192
1193 #[test]
1194 fn test_quarto_code_block_with_content_after() {
1195 let rule = MD031BlanksAroundFences::default();
1197 let content = "::: {.callout-note}\n```python\ncode\n```\nMore content here.\n:::";
1198 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1199 let warnings = rule.check(&ctx).unwrap();
1200 assert_eq!(
1202 warnings.len(),
1203 1,
1204 "Should require blank after code block inside div: {warnings:?}"
1205 );
1206 assert!(warnings[0].message.contains("after"));
1207 }
1208
1209 #[test]
1210 fn test_pandoc_code_block_after_div_open() {
1211 let rule = MD031BlanksAroundFences::default();
1214 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1216 let warnings = rule.check(&ctx).unwrap();
1217 assert!(
1218 warnings.is_empty(),
1219 "MD031 should not require blank line after Pandoc div opening: {warnings:?}"
1220 );
1221 }
1222
1223 #[test]
1224 fn test_md031_html_comment() {
1225 let rule = MD031BlanksAroundFences::default();
1226 let content = "text <!--\n```rust\nconst x = 1;\n```\n-->";
1227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1228 let warnings = rule.check(&ctx).unwrap();
1229 assert!(
1230 warnings.is_empty(),
1231 "MD031 should not require blank lines around fenced blocks inside HTML comments: {warnings:?}"
1232 );
1233 }
1234
1235 #[test]
1236 fn test_md031_block_attribute_after_fence_not_flagged() {
1237 let rule = MD031BlanksAroundFences::default();
1240 let content = "Some text.\n\n```rust\nlet x = 1;\n```\n{class=\"highlight\"}\n\nMore text.";
1241
1242 for flavor in [
1243 crate::config::MarkdownFlavor::Hugo,
1244 crate::config::MarkdownFlavor::MkDocs,
1245 crate::config::MarkdownFlavor::Kramdown,
1246 ] {
1247 let ctx = LintContext::new(content, flavor, None);
1248 let warnings = rule.check(&ctx).unwrap();
1249 assert!(
1250 warnings.is_empty(),
1251 "MD031 should not flag the block attribute line under {flavor:?}: {warnings:?}"
1252 );
1253 }
1254
1255 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 let warnings_std = rule.check(&ctx_std).unwrap();
1259 assert!(
1260 warnings_std
1261 .iter()
1262 .any(|w| w.message == "No blank line after fenced code block"),
1263 "MD031 must flag the missing blank after the fence under Standard: {warnings_std:?}"
1264 );
1265 }
1266}
1267
1268#[cfg(test)]
1273mod bare_marker_tests {
1274 use super::*;
1275 use crate::config::MarkdownFlavor;
1276 use crate::lint_context::LintContext;
1277
1278 fn fixed_with(rule: &MD031BlanksAroundFences, content: &str, flavor: MarkdownFlavor) -> String {
1281 let ctx = LintContext::new(content, flavor, None);
1282 let by_fix = rule.fix(&ctx).unwrap();
1283 let warnings = rule.check(&ctx).unwrap();
1284 let by_warnings = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
1285 assert_eq!(by_fix, by_warnings, "{content:?}: fix and warnings disagree");
1286 by_fix
1287 }
1288
1289 fn fixed(content: &str) -> String {
1290 fixed_with(&MD031BlanksAroundFences::default(), content, MarkdownFlavor::Standard)
1291 }
1292
1293 fn warnings_of(content: &str) -> Vec<String> {
1294 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1295 MD031BlanksAroundFences::default()
1296 .check(&ctx)
1297 .unwrap()
1298 .into_iter()
1299 .map(|w| format!("{}:{}", w.line, w.message))
1300 .collect()
1301 }
1302
1303 #[test]
1304 fn a_fence_under_a_bare_marker_gets_no_blank_line_above() {
1305 for content in [
1306 "-\n ```\n x\n ```\n",
1307 "+\n ```\n x\n ```\n",
1308 "*\n ```\n x\n ```\n",
1309 "1.\n ```\n x\n ```\n",
1310 "1)\n ```\n x\n ```\n",
1311 "10.\n ```\n x\n ```\n",
1312 "- \n ```\n x\n ```\n",
1313 "-\n ~~~\n x\n ~~~\n",
1314 "-\n ````rust\n x\n ````\n",
1315 "- a\n\n -\n ```\n x\n ```\n",
1316 "-\n -\n ```\n x\n ```\n",
1317 "- -\n ```\n x\n ```\n",
1318 "1. -\n ```\n x\n ```\n",
1319 "> -\n> ```\n> x\n> ```\n",
1320 "> 1.\n> ```\n> x\n> ```\n",
1321 "> > -\n> > ```\n> > x\n> > ```\n",
1322 "1.\n - ```\n x\n ```\n",
1325 "-\n 1. ```\n x\n ```\n",
1326 ] {
1327 assert_eq!(warnings_of(content), Vec::<String>::new(), "{content:?}");
1328 assert_eq!(fixed(content), content, "{content:?}");
1329 }
1330 }
1331
1332 #[test]
1333 fn the_list_items_option_does_not_change_that() {
1334 for list_items in [true, false] {
1335 let rule = MD031BlanksAroundFences::new(list_items);
1336 let content = "-\n ```\n x\n ```\n";
1337 assert_eq!(fixed_with(&rule, content, MarkdownFlavor::Standard), content);
1338 }
1339 }
1340
1341 #[test]
1342 fn a_blank_line_is_still_added_where_it_changes_no_container() {
1343 for (content, expected) in [
1344 ("a\n```\nx\n```\n", "a\n\n```\nx\n```\n"),
1345 ("- a\n ```\n x\n ```\n", "- a\n\n ```\n x\n ```\n"),
1346 ("-\n```\nx\n```\n", "-\n\n```\nx\n```\n"),
1348 ("-\n ```\n x\n ```\n", "-\n\n ```\n x\n ```\n"),
1349 ("- -\n ```\n x\n ```\n", "- -\n\n ```\n x\n ```\n"),
1352 ("a\n-\n ```\n x\n ```\n", "a\n-\n\n ```\n x\n ```\n"),
1354 ("-\n ```\n x\n ```\n after\n", "-\n ```\n x\n ```\n\n after\n"),
1356 (
1358 "- a\n -\n ```\n x\n ```\n",
1359 "- a\n -\n\n ```\n x\n ```\n",
1360 ),
1361 ("a\n- ```\n x\n ```\n", "a\n\n- ```\n x\n ```\n"),
1363 ] {
1364 assert_eq!(fixed(content), expected, "{content:?}");
1365 assert_eq!(fixed(expected), expected, "{expected:?} is not stable");
1366 }
1367 }
1368
1369 #[test]
1370 fn an_unclosed_fence_has_no_closing_line_to_follow() {
1371 for content in [
1372 "> ```\nfoo\n",
1373 "> ```\n> ~~~\nfoo\n",
1374 "> ````\n> ```\nfoo\n",
1375 "> ```\n> ```\nfoo\n",
1376 "> ```\n> \u{a0}```\nfoo\n",
1377 "> ```\n> ```\u{a0}\nfoo\n",
1378 "> ```\n> ",
1379 "```\n ",
1380 "> ```\n> ~~~\n> ",
1381 "- ```\nfoo\n",
1382 "-\n ```\nfoo\n",
1383 "-\n ```\n x\nfoo\n",
1384 ] {
1385 assert_eq!(fixed(content), content, "{content:?}");
1386 }
1387 }
1388
1389 #[test]
1390 fn a_closed_fence_still_gets_a_blank_line_after() {
1391 for (content, expected) in [
1392 ("> ```\n> x\n> ```\nfoo\n", "> ```\n> x\n> ```\n>\nfoo\n"),
1393 ("> ```\n> ```\nfoo\n", "> ```\n> ```\n>\nfoo\n"),
1394 ("> ````\n> x\n> `````\nfoo\n", "> ````\n> x\n> `````\n>\nfoo\n"),
1395 ("```\nx\n```\nfoo\n", "```\nx\n```\n\nfoo\n"),
1396 ] {
1397 assert_eq!(fixed(content), expected, "{content:?}");
1398 }
1399 }
1400
1401 #[test]
1402 fn an_admonition_after_an_unclosed_fence_is_still_checked() {
1403 let rule = MD031BlanksAroundFences::default();
1404 let content = "> ```\nplain\n!!! note\n body\nafter\n";
1405 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1406 let messages: Vec<String> = rule.check(&ctx).unwrap().into_iter().map(|w| w.message).collect();
1407 assert_eq!(
1408 messages,
1409 vec![
1410 "No blank line before admonition block",
1411 "No blank line after admonition block"
1412 ]
1413 );
1414 }
1415
1416 #[test]
1417 fn colon_fences_and_directives_stay_under_a_bare_marker() {
1418 let rule = MD031BlanksAroundFences::default();
1419 let colon = "-\n :::python\n x\n :::\n";
1420 assert_eq!(fixed_with(&rule, colon, MarkdownFlavor::AzureDevOps), colon);
1421 let myst = "-\n :::{note}\n x\n :::\n";
1422 assert_eq!(fixed_with(&rule, myst, MarkdownFlavor::MyST), myst);
1423 let admonition = "-\n !!! note\n body\n";
1424 assert_eq!(fixed_with(&rule, admonition, MarkdownFlavor::MkDocs), admonition);
1425 assert_eq!(
1427 fixed_with(&rule, "a\n:::python\nx\n:::\n", MarkdownFlavor::AzureDevOps),
1428 "a\n\n:::python\nx\n:::\n"
1429 );
1430 assert_eq!(
1431 fixed_with(&rule, "a\n!!! note\n body\n", MarkdownFlavor::MkDocs),
1432 "a\n\n!!! note\n body\n"
1433 );
1434 }
1435}