1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::calculate_indentation_width_default;
7use crate::utils::kramdown_utils::is_kramdown_block_attribute;
8use crate::utils::mkdocs_admonitions;
9use crate::utils::pandoc;
10use crate::utils::range_utils::calculate_line_range;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "kebab-case")]
16pub struct MD031Config {
17 #[serde(default = "default_list_items")]
19 pub list_items: bool,
20}
21
22impl Default for MD031Config {
23 fn default() -> Self {
24 Self {
25 list_items: default_list_items(),
26 }
27 }
28}
29
30fn default_list_items() -> bool {
31 true
32}
33
34impl RuleConfig for MD031Config {
35 const RULE_NAME: &'static str = "MD031";
36}
37
38#[derive(Clone, Default)]
40pub struct MD031BlanksAroundFences {
41 config: MD031Config,
42}
43
44impl MD031BlanksAroundFences {
45 pub fn new(list_items: bool) -> Self {
46 Self {
47 config: MD031Config { list_items },
48 }
49 }
50
51 pub fn from_config_struct(config: MD031Config) -> Self {
52 Self { config }
53 }
54
55 fn is_effectively_empty_line(line_idx: usize, lines: &[&str], ctx: &crate::lint_context::LintContext) -> bool {
58 let line = lines.get(line_idx).unwrap_or(&"");
59
60 if line.trim().is_empty() {
62 return true;
63 }
64
65 if let Some(line_info) = ctx.lines.get(line_idx)
67 && let Some(ref bq) = line_info.blockquote
68 {
69 return bq.content.trim().is_empty();
71 }
72
73 false
74 }
75
76 fn is_in_list(&self, line_index: usize, lines: &[&str]) -> bool {
78 for i in (0..=line_index).rev() {
80 let line = lines[i];
81 let trimmed = line.trim_start();
82
83 if trimmed.is_empty() {
85 return false;
86 }
87
88 if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
90 let mut chars = trimmed.chars().skip_while(char::is_ascii_digit);
91 if let Some(next) = chars.next()
92 && (next == '.' || next == ')')
93 && chars.next() == Some(' ')
94 {
95 return true;
96 }
97 }
98
99 if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
101 return true;
102 }
103
104 let is_indented = calculate_indentation_width_default(line) >= 3;
106 if is_indented {
107 continue; }
109
110 return false;
113 }
114
115 false
116 }
117
118 fn should_require_blank_line(&self, line_index: usize, lines: &[&str]) -> bool {
120 if self.config.list_items {
121 true
123 } else {
124 !self.is_in_list(line_index, lines)
126 }
127 }
128
129 fn is_right_after_frontmatter(line_index: usize, ctx: &crate::lint_context::LintContext) -> bool {
131 line_index > 0
132 && ctx.lines.get(line_index - 1).is_some_and(|info| info.in_front_matter)
133 && ctx.lines.get(line_index).is_some_and(|info| !info.in_front_matter)
134 }
135
136 fn fenced_block_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
141 let lines = ctx.raw_lines();
142
143 ctx.code_block_details
144 .iter()
145 .filter(|d| d.is_fenced)
146 .map(|detail| {
147 let start_line = ctx
149 .line_offsets
150 .partition_point(|&off| off <= detail.start)
151 .saturating_sub(1);
152
153 let end_byte = if detail.end > 0 { detail.end - 1 } else { 0 };
155 let end_line = ctx
156 .line_offsets
157 .partition_point(|&off| off <= end_byte)
158 .saturating_sub(1);
159
160 let end_line_content = lines.get(end_line).unwrap_or(&"");
162 let trimmed = end_line_content.trim();
163 let content_after_bq = if trimmed.starts_with('>') {
164 trimmed.trim_start_matches(['>', ' ']).trim()
165 } else {
166 trimmed
167 };
168 let is_closing_fence = (content_after_bq.starts_with("```") || content_after_bq.starts_with("~~~"))
169 && content_after_bq
170 .chars()
171 .skip_while(|&c| c == '`' || c == '~')
172 .all(char::is_whitespace);
173
174 if is_closing_fence {
175 (start_line, end_line)
176 } else {
177 (start_line, lines.len().saturating_sub(1))
178 }
179 })
180 .collect()
181 }
182
183 fn colon_fence_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
185 ctx.colon_fence_ranges()
186 .iter()
187 .map(|&(start, end)| {
188 let start_line = ctx.line_offsets.partition_point(|&off| off <= start).saturating_sub(1);
189 let end_byte = if end > 0 { end - 1 } else { 0 };
190 let end_line = ctx
191 .line_offsets
192 .partition_point(|&off| off <= end_byte)
193 .saturating_sub(1);
194 (start_line, end_line)
195 })
196 .collect()
197 }
198
199 fn myst_directive_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
201 ctx.myst_directive_ranges()
202 .iter()
203 .map(|&(start, end)| {
204 let start_line = ctx.line_offsets.partition_point(|&off| off <= start).saturating_sub(1);
205 let end_byte = if end > 0 { end - 1 } else { 0 };
206 let end_line = ctx
207 .line_offsets
208 .partition_point(|&off| off <= end_byte)
209 .saturating_sub(1);
210 (start_line, end_line)
211 })
212 .collect()
213 }
214}
215
216impl Rule for MD031BlanksAroundFences {
217 fn name(&self) -> &'static str {
218 "MD031"
219 }
220
221 fn description(&self) -> &'static str {
222 "Fenced code blocks should be surrounded by blank lines"
223 }
224
225 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
226 let line_index = &ctx.line_index;
227
228 let mut warnings = Vec::new();
229 let lines = ctx.raw_lines();
230 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
231 let is_pandoc = ctx.flavor.is_pandoc_compatible();
232
233 let fenced_blocks = Self::fenced_block_line_ranges(ctx);
235
236 let is_pandoc_div_marker =
238 |line: &str| -> bool { is_pandoc && (pandoc::is_div_open(line) || pandoc::is_div_close(line)) };
239
240 for (opening_line, closing_line) in &fenced_blocks {
242 if ctx
244 .line_info(*opening_line + 1)
245 .is_some_and(|info| info.in_pymdown_block)
246 {
247 continue;
248 }
249
250 let prev_line_is_pandoc_marker = *opening_line > 0 && is_pandoc_div_marker(lines[*opening_line - 1]);
255 if *opening_line > 0
256 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
257 && !Self::is_right_after_frontmatter(*opening_line, ctx)
258 && !prev_line_is_pandoc_marker
259 && self.should_require_blank_line(*opening_line, lines)
260 {
261 let (start_line, start_col, end_line, end_col) =
262 calculate_line_range(*opening_line + 1, lines[*opening_line]);
263
264 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
265 warnings.push(LintWarning {
266 rule_name: Some(self.name().to_string()),
267 line: start_line,
268 column: start_col,
269 end_line,
270 end_column: end_col,
271 message: "No blank line before fenced code block".to_string(),
272 severity: Severity::Warning,
273 fix: Some(Fix::new(
274 line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
275 format!("{bq_prefix}\n"),
276 )),
277 });
278 }
279
280 let next_line_is_pandoc_marker =
285 *closing_line + 1 < lines.len() && is_pandoc_div_marker(lines[*closing_line + 1]);
286 if *closing_line + 1 < lines.len()
287 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
288 && !is_kramdown_block_attribute(lines[*closing_line + 1])
289 && !next_line_is_pandoc_marker
290 && self.should_require_blank_line(*closing_line, lines)
291 {
292 let (start_line, start_col, end_line, end_col) =
293 calculate_line_range(*closing_line + 1, lines[*closing_line]);
294
295 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
296 warnings.push(LintWarning {
297 rule_name: Some(self.name().to_string()),
298 line: start_line,
299 column: start_col,
300 end_line,
301 end_column: end_col,
302 message: "No blank line after fenced code block".to_string(),
303 severity: Severity::Warning,
304 fix: Some(Fix::new(
305 line_index.line_col_to_byte_range_with_length(*closing_line + 2, 1, 0),
306 format!("{bq_prefix}\n"),
307 )),
308 });
309 }
310 }
311
312 if ctx.flavor.supports_colon_code_fences() {
314 let colon_blocks = Self::colon_fence_line_ranges(ctx);
315 for (opening_line, closing_line) in &colon_blocks {
316 if *opening_line > 0
318 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
319 && !Self::is_right_after_frontmatter(*opening_line, ctx)
320 && self.should_require_blank_line(*opening_line, lines)
321 {
322 let (start_line, start_col, end_line, end_col) =
323 calculate_line_range(*opening_line + 1, lines[*opening_line]);
324 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
325 warnings.push(LintWarning {
326 rule_name: Some(self.name().to_string()),
327 line: start_line,
328 column: start_col,
329 end_line,
330 end_column: end_col,
331 message: "No blank line before colon code fence".to_string(),
332 severity: Severity::Warning,
333 fix: Some(Fix::new(
334 line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
335 format!("{bq_prefix}\n"),
336 )),
337 });
338 }
339
340 if *closing_line + 1 < lines.len()
342 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
343 && self.should_require_blank_line(*closing_line, lines)
344 {
345 let (start_line, start_col, end_line, end_col) =
346 calculate_line_range(*closing_line + 1, lines[*closing_line]);
347 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
348 warnings.push(LintWarning {
349 rule_name: Some(self.name().to_string()),
350 line: start_line,
351 column: start_col,
352 end_line,
353 end_column: end_col,
354 message: "No blank line after colon code fence".to_string(),
355 severity: Severity::Warning,
356 fix: Some(Fix::new(
357 line_index.line_col_to_byte_range_with_length(*closing_line + 2, 1, 0),
358 format!("{bq_prefix}\n"),
359 )),
360 });
361 }
362 }
363 }
364
365 if ctx.flavor.supports_myst_directives() {
367 let myst_blocks = Self::myst_directive_line_ranges(ctx);
368 for (opening_line, closing_line) in &myst_blocks {
369 if *opening_line > 0
371 && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
372 && !Self::is_right_after_frontmatter(*opening_line, ctx)
373 && self.should_require_blank_line(*opening_line, lines)
374 {
375 let (start_line, start_col, end_line, end_col) =
376 calculate_line_range(*opening_line + 1, lines[*opening_line]);
377 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
378 warnings.push(LintWarning {
379 rule_name: Some(self.name().to_string()),
380 line: start_line,
381 column: start_col,
382 end_line,
383 end_column: end_col,
384 message: "No blank line before MyST directive".to_string(),
385 severity: Severity::Warning,
386 fix: Some(Fix::new(
387 line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
388 format!("{bq_prefix}\n"),
389 )),
390 });
391 }
392
393 if *closing_line + 1 < lines.len()
395 && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
396 && self.should_require_blank_line(*closing_line, lines)
397 {
398 let (start_line, start_col, end_line, end_col) =
399 calculate_line_range(*closing_line + 1, lines[*closing_line]);
400 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
401 warnings.push(LintWarning {
402 rule_name: Some(self.name().to_string()),
403 line: start_line,
404 column: start_col,
405 end_line,
406 end_column: end_col,
407 message: "No blank line after MyST directive".to_string(),
408 severity: Severity::Warning,
409 fix: Some(Fix::new(
410 line_index.line_col_to_byte_range_with_length(*closing_line + 2, 1, 0),
411 format!("{bq_prefix}\n"),
412 )),
413 });
414 }
415 }
416 }
417
418 if is_mkdocs {
420 let mut in_admonition = false;
421 let mut admonition_indent = 0;
422 let mut i = 0;
423
424 while i < lines.len() {
425 let line = lines[i];
426
427 let in_fenced_block = fenced_blocks.iter().any(|(start, end)| i >= *start && i <= *end);
429 if in_fenced_block {
430 i += 1;
431 continue;
432 }
433
434 if ctx.line_info(i + 1).is_some_and(|info| info.in_pymdown_block) {
436 i += 1;
437 continue;
438 }
439
440 if mkdocs_admonitions::is_admonition_start(line) {
442 if i > 0
444 && !Self::is_effectively_empty_line(i - 1, lines, ctx)
445 && !Self::is_right_after_frontmatter(i, ctx)
446 && self.should_require_blank_line(i, lines)
447 {
448 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
449
450 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
451 warnings.push(LintWarning {
452 rule_name: Some(self.name().to_string()),
453 line: start_line,
454 column: start_col,
455 end_line,
456 end_column: end_col,
457 message: "No blank line before admonition block".to_string(),
458 severity: Severity::Warning,
459 fix: Some(Fix::new(
460 line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
461 format!("{bq_prefix}\n"),
462 )),
463 });
464 }
465
466 in_admonition = true;
467 admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
468 i += 1;
469 continue;
470 }
471
472 if in_admonition
474 && !line.trim().is_empty()
475 && !mkdocs_admonitions::is_admonition_content(line, admonition_indent)
476 {
477 in_admonition = false;
478
479 if i > 0
483 && !Self::is_effectively_empty_line(i - 1, lines, ctx)
484 && self.should_require_blank_line(i - 1, lines)
485 {
486 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
487
488 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
489 warnings.push(LintWarning {
490 rule_name: Some(self.name().to_string()),
491 line: start_line,
492 column: start_col,
493 end_line,
494 end_column: end_col,
495 message: "No blank line after admonition block".to_string(),
496 severity: Severity::Warning,
497 fix: Some(Fix::new(
498 line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
499 format!("{bq_prefix}\n"),
500 )),
501 });
502 }
503
504 admonition_indent = 0;
505 }
506
507 i += 1;
508 }
509 }
510
511 Ok(warnings)
512 }
513
514 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
515 if self.should_skip(ctx) {
516 return Ok(ctx.content.to_string());
517 }
518 let warnings = self.check(ctx)?;
519 if warnings.is_empty() {
520 return Ok(ctx.content.to_string());
521 }
522 let warnings =
523 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
524 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
525 .map_err(crate::rule::LintError::InvalidInput)
526 }
527
528 fn category(&self) -> RuleCategory {
530 RuleCategory::CodeBlock
531 }
532
533 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
535 if ctx.content.is_empty() {
536 return true;
537 }
538 let has_fences = ctx.likely_has_code() || ctx.has_char('~');
539 let has_mkdocs_admonitions = ctx.flavor == crate::config::MarkdownFlavor::MkDocs && ctx.content.contains("!!!");
540 let has_colon_fences = ctx.flavor.supports_colon_code_fences() && ctx.content.contains(":::");
541 let has_myst_directives = ctx.flavor.supports_myst_directives() && ctx.content.contains(":::");
542 !has_fences && !has_mkdocs_admonitions && !has_colon_fences && !has_myst_directives
543 }
544
545 fn as_any(&self) -> &dyn std::any::Any {
546 self
547 }
548
549 crate::impl_rule_config_methods!(MD031Config);
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use crate::lint_context::LintContext;
556
557 #[test]
558 fn test_basic_functionality() {
559 let rule = MD031BlanksAroundFences::default();
560
561 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564 let warnings = rule.check(&ctx).unwrap();
565 assert!(
566 warnings.is_empty(),
567 "Expected no warnings for properly formatted code blocks"
568 );
569
570 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573 let warnings = rule.check(&ctx).unwrap();
574 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
575 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
576 assert!(
577 warnings[0].message.contains("before"),
578 "Warning should be about blank line before"
579 );
580
581 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
584 let warnings = rule.check(&ctx).unwrap();
585 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
586 assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
587 assert!(
588 warnings[0].message.contains("after"),
589 "Warning should be about blank line after"
590 );
591
592 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595 let warnings = rule.check(&ctx).unwrap();
596 assert_eq!(
597 warnings.len(),
598 2,
599 "Expected 2 warnings for missing blank lines before and after"
600 );
601 }
602
603 #[test]
604 fn test_nested_code_blocks() {
605 let rule = MD031BlanksAroundFences::default();
606
607 let content = r#"````markdown
609```
610content
611```
612````"#;
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614 let warnings = rule.check(&ctx).unwrap();
615 assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
616
617 let fixed = rule.fix(&ctx).unwrap();
619 assert_eq!(fixed, content, "Fix should not modify nested code blocks");
620 }
621
622 #[test]
623 fn test_nested_code_blocks_complex() {
624 let rule = MD031BlanksAroundFences::default();
625
626 let content = r#"# Documentation
628
629## Examples
630
631````markdown
632```python
633def hello():
634 print("Hello, world!")
635```
636
637```javascript
638console.log("Hello, world!");
639```
640````
641
642More text here."#;
643
644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645 let warnings = rule.check(&ctx).unwrap();
646 assert_eq!(
647 warnings.len(),
648 0,
649 "Should not flag any issues in properly formatted nested code blocks"
650 );
651
652 let content_5 = r#"`````markdown
654````python
655```bash
656echo "nested"
657```
658````
659`````"#;
660
661 let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
662 let warnings_5 = rule.check(&ctx_5).unwrap();
663 assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
664 }
665
666 #[test]
667 fn test_fix_preserves_trailing_newline() {
668 let rule = MD031BlanksAroundFences::default();
669
670 let content = "Some text\n```\ncode\n```\nMore text\n";
672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
673 let fixed = rule.fix(&ctx).unwrap();
674
675 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
677 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
678 }
679
680 #[test]
681 fn test_fix_preserves_no_trailing_newline() {
682 let rule = MD031BlanksAroundFences::default();
683
684 let content = "Some text\n```\ncode\n```\nMore text";
686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
687 let fixed = rule.fix(&ctx).unwrap();
688
689 assert!(
691 !fixed.ends_with('\n'),
692 "Fix should not add trailing newline if original didn't have one"
693 );
694 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
695 }
696
697 #[test]
698 fn test_list_items_config_true() {
699 let rule = MD031BlanksAroundFences::new(true);
701
702 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704 let warnings = rule.check(&ctx).unwrap();
705
706 assert_eq!(warnings.len(), 2);
708 assert!(warnings[0].message.contains("before"));
709 assert!(warnings[1].message.contains("after"));
710 }
711
712 #[test]
713 fn test_list_items_config_false() {
714 let rule = MD031BlanksAroundFences::new(false);
716
717 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
719 let warnings = rule.check(&ctx).unwrap();
720
721 assert_eq!(warnings.len(), 0);
723 }
724
725 #[test]
726 fn test_list_items_config_false_outside_list() {
727 let rule = MD031BlanksAroundFences::new(false);
729
730 let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
732 let warnings = rule.check(&ctx).unwrap();
733
734 assert_eq!(warnings.len(), 2);
736 assert!(warnings[0].message.contains("before"));
737 assert!(warnings[1].message.contains("after"));
738 }
739
740 #[test]
741 fn test_default_config_section() {
742 let rule = MD031BlanksAroundFences::default();
743 let config_section = rule.default_config_section();
744
745 assert!(config_section.is_some());
746 let (name, value) = config_section.unwrap();
747 assert_eq!(name, "MD031");
748
749 if let toml::Value::Table(table) = value {
751 assert!(table.contains_key("list-items"));
752 assert_eq!(table["list-items"], toml::Value::Boolean(true));
753 } else {
754 panic!("Expected TOML table");
755 }
756 }
757
758 #[test]
759 fn test_fix_list_items_config_false() {
760 let rule = MD031BlanksAroundFences::new(false);
762
763 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
765 let fixed = rule.fix(&ctx).unwrap();
766
767 assert_eq!(fixed, content);
769 }
770
771 #[test]
772 fn test_fix_list_items_config_true() {
773 let rule = MD031BlanksAroundFences::new(true);
775
776 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
778 let fixed = rule.fix(&ctx).unwrap();
779
780 let expected = "1. First item\n\n ```python\n code()\n ```\n\n2. Second item";
782 assert_eq!(fixed, expected);
783 }
784
785 #[test]
786 fn test_no_warning_after_frontmatter() {
787 let rule = MD031BlanksAroundFences::default();
790
791 let content = "---\ntitle: Test\n---\n```\ncode\n```";
792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
793 let warnings = rule.check(&ctx).unwrap();
794
795 assert!(
797 warnings.is_empty(),
798 "Expected no warnings for code block after frontmatter, got: {warnings:?}"
799 );
800 }
801
802 #[test]
803 fn test_fix_does_not_add_blank_after_frontmatter() {
804 let rule = MD031BlanksAroundFences::default();
806
807 let content = "---\ntitle: Test\n---\n```\ncode\n```";
808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
809 let fixed = rule.fix(&ctx).unwrap();
810
811 assert_eq!(fixed, content);
813 }
814
815 #[test]
816 fn test_frontmatter_with_blank_line_before_code() {
817 let rule = MD031BlanksAroundFences::default();
819
820 let content = "---\ntitle: Test\n---\n\n```\ncode\n```";
821 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
822 let warnings = rule.check(&ctx).unwrap();
823
824 assert!(warnings.is_empty());
825 }
826
827 #[test]
828 fn test_no_warning_for_admonition_after_frontmatter() {
829 let rule = MD031BlanksAroundFences::default();
831
832 let content = "---\ntitle: Test\n---\n!!! note\n This is a note";
833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
834 let warnings = rule.check(&ctx).unwrap();
835
836 assert!(
837 warnings.is_empty(),
838 "Expected no warnings for admonition after frontmatter, got: {warnings:?}"
839 );
840 }
841
842 #[test]
843 fn test_toml_frontmatter_before_code() {
844 let rule = MD031BlanksAroundFences::default();
846
847 let content = "+++\ntitle = \"Test\"\n+++\n```\ncode\n```";
848 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
849 let warnings = rule.check(&ctx).unwrap();
850
851 assert!(
852 warnings.is_empty(),
853 "Expected no warnings for code block after TOML frontmatter, got: {warnings:?}"
854 );
855 }
856
857 #[test]
858 fn test_fenced_code_in_list_with_4_space_indent_issue_276() {
859 let rule = MD031BlanksAroundFences::new(true);
863
864 let content =
866 "1. First item\n2. Second item with code:\n ```python\n print(\"Hello\")\n ```\n3. Third item";
867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
868 let warnings = rule.check(&ctx).unwrap();
869
870 assert_eq!(
872 warnings.len(),
873 2,
874 "Should detect fenced code in list with 4-space indent, got: {warnings:?}"
875 );
876 assert!(warnings[0].message.contains("before"));
877 assert!(warnings[1].message.contains("after"));
878
879 let fixed = rule.fix(&ctx).unwrap();
881 let expected =
882 "1. First item\n2. Second item with code:\n\n ```python\n print(\"Hello\")\n ```\n\n3. Third item";
883 assert_eq!(
884 fixed, expected,
885 "Fix should add blank lines around list-indented fenced code"
886 );
887 }
888
889 #[test]
890 fn test_fenced_code_in_list_with_mixed_indentation() {
891 let rule = MD031BlanksAroundFences::new(true);
893
894 let content = r#"# Test
895
8963-space indent:
8971. First item
898 ```python
899 code
900 ```
9012. Second item
902
9034-space indent:
9041. First item
905 ```python
906 code
907 ```
9082. Second item"#;
909
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911 let warnings = rule.check(&ctx).unwrap();
912
913 assert_eq!(
915 warnings.len(),
916 4,
917 "Should detect all fenced code blocks regardless of indentation, got: {warnings:?}"
918 );
919 }
920
921 #[test]
922 fn test_fix_preserves_blockquote_prefix_before_fence() {
923 let rule = MD031BlanksAroundFences::default();
925
926 let content = "> Text before
927> ```
928> code
929> ```";
930 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
931 let fixed = rule.fix(&ctx).unwrap();
932
933 let expected = "> Text before
935>
936> ```
937> code
938> ```";
939 assert_eq!(
940 fixed, expected,
941 "Fix should insert '>' blank line, not plain blank line"
942 );
943 }
944
945 #[test]
946 fn test_fix_preserves_blockquote_prefix_after_fence() {
947 let rule = MD031BlanksAroundFences::default();
949
950 let content = "> ```
951> code
952> ```
953> Text after";
954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955 let fixed = rule.fix(&ctx).unwrap();
956
957 let expected = "> ```
959> code
960> ```
961>
962> Text after";
963 assert_eq!(
964 fixed, expected,
965 "Fix should insert '>' blank line after fence, not plain blank line"
966 );
967 }
968
969 #[test]
970 fn test_fix_preserves_nested_blockquote_prefix() {
971 let rule = MD031BlanksAroundFences::default();
973
974 let content = ">> Nested quote
975>> ```
976>> code
977>> ```
978>> More text";
979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
980 let fixed = rule.fix(&ctx).unwrap();
981
982 let expected = ">> Nested quote
984>>
985>> ```
986>> code
987>> ```
988>>
989>> More text";
990 assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
991 }
992
993 #[test]
994 fn test_fix_preserves_triple_nested_blockquote_prefix() {
995 let rule = MD031BlanksAroundFences::default();
997
998 let content = ">>> Triple nested
999>>> ```
1000>>> code
1001>>> ```
1002>>> More text";
1003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004 let fixed = rule.fix(&ctx).unwrap();
1005
1006 let expected = ">>> Triple nested
1007>>>
1008>>> ```
1009>>> code
1010>>> ```
1011>>>
1012>>> More text";
1013 assert_eq!(
1014 fixed, expected,
1015 "Fix should preserve triple-nested blockquote prefix '>>>'"
1016 );
1017 }
1018
1019 #[test]
1022 fn test_quarto_code_block_after_div_open() {
1023 let rule = MD031BlanksAroundFences::default();
1025 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1027 let warnings = rule.check(&ctx).unwrap();
1028 assert!(
1029 warnings.is_empty(),
1030 "Should not require blank line after Quarto div opening: {warnings:?}"
1031 );
1032 }
1033
1034 #[test]
1035 fn test_quarto_code_block_before_div_close() {
1036 let rule = MD031BlanksAroundFences::default();
1038 let content = "::: {.callout-note}\nSome text\n```python\ncode\n```\n:::";
1039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1040 let warnings = rule.check(&ctx).unwrap();
1041 assert!(
1043 warnings.len() <= 1,
1044 "Should not require blank line before Quarto div closing: {warnings:?}"
1045 );
1046 }
1047
1048 #[test]
1049 fn test_quarto_code_block_outside_div_still_requires_blanks() {
1050 let rule = MD031BlanksAroundFences::default();
1052 let content = "Some text\n```python\ncode\n```\nMore text";
1053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1054 let warnings = rule.check(&ctx).unwrap();
1055 assert_eq!(
1056 warnings.len(),
1057 2,
1058 "Should still require blank lines around code blocks outside divs"
1059 );
1060 }
1061
1062 #[test]
1063 fn test_quarto_code_block_with_callout_note() {
1064 let rule = MD031BlanksAroundFences::default();
1066 let content = "::: {.callout-note}\n```r\n1 + 1\n```\n:::\n\nMore text";
1067 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1068 let warnings = rule.check(&ctx).unwrap();
1069 assert!(
1070 warnings.is_empty(),
1071 "Callout note with code block should have no warnings: {warnings:?}"
1072 );
1073 }
1074
1075 #[test]
1076 fn test_quarto_nested_divs_with_code() {
1077 let rule = MD031BlanksAroundFences::default();
1079 let content = "::: {.outer}\n::: {.inner}\n```python\ncode\n```\n:::\n:::\n";
1080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1081 let warnings = rule.check(&ctx).unwrap();
1082 assert!(
1083 warnings.is_empty(),
1084 "Nested divs with code blocks should have no warnings: {warnings:?}"
1085 );
1086 }
1087
1088 #[test]
1089 fn test_quarto_div_markers_in_standard_flavor() {
1090 let rule = MD031BlanksAroundFences::default();
1092 let content = "::: {.callout-note}\n```python\ncode\n```\n:::\n";
1093 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1094 let warnings = rule.check(&ctx).unwrap();
1095 assert!(
1098 !warnings.is_empty(),
1099 "Standard flavor should require blanks around code blocks: {warnings:?}"
1100 );
1101 }
1102
1103 #[test]
1104 fn test_quarto_fix_does_not_add_blanks_at_div_boundaries() {
1105 let rule = MD031BlanksAroundFences::default();
1107 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1109 let fixed = rule.fix(&ctx).unwrap();
1110 assert_eq!(fixed, content, "Fix should not add blanks at Quarto div boundaries");
1112 }
1113
1114 #[test]
1115 fn test_quarto_code_block_with_content_before() {
1116 let rule = MD031BlanksAroundFences::default();
1118 let content = "::: {.callout-note}\nHere is some code:\n```python\ncode\n```\n:::";
1119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1120 let warnings = rule.check(&ctx).unwrap();
1121 assert_eq!(
1123 warnings.len(),
1124 1,
1125 "Should require blank before code block inside div: {warnings:?}"
1126 );
1127 assert!(warnings[0].message.contains("before"));
1128 }
1129
1130 #[test]
1131 fn test_quarto_code_block_with_content_after() {
1132 let rule = MD031BlanksAroundFences::default();
1134 let content = "::: {.callout-note}\n```python\ncode\n```\nMore content here.\n:::";
1135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1136 let warnings = rule.check(&ctx).unwrap();
1137 assert_eq!(
1139 warnings.len(),
1140 1,
1141 "Should require blank after code block inside div: {warnings:?}"
1142 );
1143 assert!(warnings[0].message.contains("after"));
1144 }
1145
1146 #[test]
1147 fn test_pandoc_code_block_after_div_open() {
1148 let rule = MD031BlanksAroundFences::default();
1151 let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1153 let warnings = rule.check(&ctx).unwrap();
1154 assert!(
1155 warnings.is_empty(),
1156 "MD031 should not require blank line after Pandoc div opening: {warnings:?}"
1157 );
1158 }
1159}