1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::element_cache::ElementCache;
7use crate::utils::kramdown_utils::is_kramdown_block_attribute;
8use crate::utils::mkdocs_admonitions;
9use crate::utils::range_utils::calculate_line_range;
10use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
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(|c| c.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 = ElementCache::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 detect_fenced_code_blocks_pulldown(
141 content: &str,
142 line_offsets: &[usize],
143 lines: &[&str],
144 ) -> Vec<(usize, usize)> {
145 let mut fenced_blocks = Vec::new();
146 let options = Options::all();
147 let parser = Parser::new_ext(content, options).into_offset_iter();
148
149 let mut current_block_start: Option<usize> = None;
150
151 let byte_to_line = |byte_offset: usize| -> usize {
153 line_offsets
154 .iter()
155 .enumerate()
156 .rev()
157 .find(|&(_, &offset)| offset <= byte_offset)
158 .map(|(idx, _)| idx)
159 .unwrap_or(0)
160 };
161
162 for (event, range) in parser {
163 match event {
164 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) => {
165 let line_idx = byte_to_line(range.start);
166 current_block_start = Some(line_idx);
167 }
168 Event::End(TagEnd::CodeBlock) => {
169 if let Some(start_line) = current_block_start.take() {
170 let end_byte = if range.end > 0 { range.end - 1 } else { 0 };
174 let end_line = byte_to_line(end_byte);
175
176 let end_line_content = lines.get(end_line).unwrap_or(&"");
179 let trimmed = end_line_content.trim();
181 let content_after_bq = if trimmed.starts_with('>') {
182 trimmed.trim_start_matches(['>', ' ']).trim()
183 } else {
184 trimmed
185 };
186 let is_closing_fence = (content_after_bq.starts_with("```")
187 || content_after_bq.starts_with("~~~"))
188 && content_after_bq
189 .chars()
190 .skip_while(|&c| c == '`' || c == '~')
191 .all(|c| c.is_whitespace());
192
193 if is_closing_fence {
194 fenced_blocks.push((start_line, end_line));
195 } else {
196 fenced_blocks.push((start_line, lines.len().saturating_sub(1)));
199 }
200 }
201 }
202 _ => {}
203 }
204 }
205
206 if let Some(start_line) = current_block_start {
208 fenced_blocks.push((start_line, lines.len().saturating_sub(1)));
209 }
210
211 fenced_blocks
212 }
213}
214
215impl Rule for MD031BlanksAroundFences {
216 fn name(&self) -> &'static str {
217 "MD031"
218 }
219
220 fn description(&self) -> &'static str {
221 "Fenced code blocks should be surrounded by blank lines"
222 }
223
224 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
225 let content = ctx.content;
226 let line_index = &ctx.line_index;
227
228 let mut warnings = Vec::new();
229 let lines: Vec<&str> = content.lines().collect();
230 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
231
232 let fenced_blocks = Self::detect_fenced_code_blocks_pulldown(content, &ctx.line_offsets, &lines);
234
235 for (opening_line, closing_line) in &fenced_blocks {
237 if *opening_line > 0
241 && !Self::is_effectively_empty_line(*opening_line - 1, &lines, ctx)
242 && !Self::is_right_after_frontmatter(*opening_line, ctx)
243 && self.should_require_blank_line(*opening_line, &lines)
244 {
245 let (start_line, start_col, end_line, end_col) =
246 calculate_line_range(*opening_line + 1, lines[*opening_line]);
247
248 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
249 warnings.push(LintWarning {
250 rule_name: Some(self.name().to_string()),
251 line: start_line,
252 column: start_col,
253 end_line,
254 end_column: end_col,
255 message: "No blank line before fenced code block".to_string(),
256 severity: Severity::Warning,
257 fix: Some(Fix {
258 range: line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
259 replacement: format!("{bq_prefix}\n"),
260 }),
261 });
262 }
263
264 if *closing_line + 1 < lines.len()
268 && !Self::is_effectively_empty_line(*closing_line + 1, &lines, ctx)
269 && !is_kramdown_block_attribute(lines[*closing_line + 1])
270 && self.should_require_blank_line(*closing_line, &lines)
271 {
272 let (start_line, start_col, end_line, end_col) =
273 calculate_line_range(*closing_line + 1, lines[*closing_line]);
274
275 let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
276 warnings.push(LintWarning {
277 rule_name: Some(self.name().to_string()),
278 line: start_line,
279 column: start_col,
280 end_line,
281 end_column: end_col,
282 message: "No blank line after fenced code block".to_string(),
283 severity: Severity::Warning,
284 fix: Some(Fix {
285 range: line_index.line_col_to_byte_range_with_length(
286 *closing_line + 1,
287 lines[*closing_line].len() + 1,
288 0,
289 ),
290 replacement: format!("{bq_prefix}\n"),
291 }),
292 });
293 }
294 }
295
296 if is_mkdocs {
298 let mut in_admonition = false;
299 let mut admonition_indent = 0;
300 let mut i = 0;
301
302 while i < lines.len() {
303 let line = lines[i];
304
305 let in_fenced_block = fenced_blocks.iter().any(|(start, end)| i >= *start && i <= *end);
307 if in_fenced_block {
308 i += 1;
309 continue;
310 }
311
312 if mkdocs_admonitions::is_admonition_start(line) {
314 if i > 0
316 && !Self::is_effectively_empty_line(i - 1, &lines, ctx)
317 && !Self::is_right_after_frontmatter(i, ctx)
318 && self.should_require_blank_line(i, &lines)
319 {
320 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
321
322 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
323 warnings.push(LintWarning {
324 rule_name: Some(self.name().to_string()),
325 line: start_line,
326 column: start_col,
327 end_line,
328 end_column: end_col,
329 message: "No blank line before admonition block".to_string(),
330 severity: Severity::Warning,
331 fix: Some(Fix {
332 range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
333 replacement: format!("{bq_prefix}\n"),
334 }),
335 });
336 }
337
338 in_admonition = true;
339 admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
340 i += 1;
341 continue;
342 }
343
344 if in_admonition
346 && !line.trim().is_empty()
347 && !mkdocs_admonitions::is_admonition_content(line, admonition_indent)
348 {
349 in_admonition = false;
350
351 if !Self::is_effectively_empty_line(i, &lines, ctx) && self.should_require_blank_line(i - 1, &lines)
353 {
354 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
355
356 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
357 warnings.push(LintWarning {
358 rule_name: Some(self.name().to_string()),
359 line: start_line,
360 column: start_col,
361 end_line,
362 end_column: end_col,
363 message: "No blank line after admonition block".to_string(),
364 severity: Severity::Warning,
365 fix: Some(Fix {
366 range: line_index.line_col_to_byte_range_with_length(i, 0, 0),
367 replacement: format!("{bq_prefix}\n"),
368 }),
369 });
370 }
371
372 admonition_indent = 0;
373 }
374
375 i += 1;
376 }
377 }
378
379 Ok(warnings)
380 }
381
382 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
383 let content = ctx.content;
384
385 let had_trailing_newline = content.ends_with('\n');
387
388 let lines: Vec<&str> = content.lines().collect();
389
390 let fenced_blocks = Self::detect_fenced_code_blocks_pulldown(content, &ctx.line_offsets, &lines);
392
393 let mut needs_blank_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
395 let mut needs_blank_after: std::collections::HashSet<usize> = std::collections::HashSet::new();
396
397 for (opening_line, closing_line) in &fenced_blocks {
398 if *opening_line > 0
401 && !Self::is_effectively_empty_line(*opening_line - 1, &lines, ctx)
402 && !Self::is_right_after_frontmatter(*opening_line, ctx)
403 && self.should_require_blank_line(*opening_line, &lines)
404 {
405 needs_blank_before.insert(*opening_line);
406 }
407
408 if *closing_line + 1 < lines.len()
411 && !Self::is_effectively_empty_line(*closing_line + 1, &lines, ctx)
412 && !is_kramdown_block_attribute(lines[*closing_line + 1])
413 && self.should_require_blank_line(*closing_line, &lines)
414 {
415 needs_blank_after.insert(*closing_line);
416 }
417 }
418
419 let mut result = Vec::new();
421 for (i, line) in lines.iter().enumerate() {
422 if needs_blank_before.contains(&i) {
424 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
425 result.push(bq_prefix);
426 }
427
428 result.push((*line).to_string());
429
430 if needs_blank_after.contains(&i) {
432 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
433 result.push(bq_prefix);
434 }
435 }
436
437 let fixed = result.join("\n");
438
439 let final_result = if had_trailing_newline && !fixed.ends_with('\n') {
441 format!("{fixed}\n")
442 } else {
443 fixed
444 };
445
446 Ok(final_result)
447 }
448
449 fn category(&self) -> RuleCategory {
451 RuleCategory::CodeBlock
452 }
453
454 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
456 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
458 }
459
460 fn as_any(&self) -> &dyn std::any::Any {
461 self
462 }
463
464 fn default_config_section(&self) -> Option<(String, toml::Value)> {
465 let default_config = MD031Config::default();
466 let json_value = serde_json::to_value(&default_config).ok()?;
467 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
468 if let toml::Value::Table(table) = toml_value {
469 if !table.is_empty() {
470 Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
471 } else {
472 None
473 }
474 } else {
475 None
476 }
477 }
478
479 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
480 where
481 Self: Sized,
482 {
483 let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
484 Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use crate::lint_context::LintContext;
492
493 #[test]
494 fn test_basic_functionality() {
495 let rule = MD031BlanksAroundFences::default();
496
497 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
500 let warnings = rule.check(&ctx).unwrap();
501 assert!(
502 warnings.is_empty(),
503 "Expected no warnings for properly formatted code blocks"
504 );
505
506 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509 let warnings = rule.check(&ctx).unwrap();
510 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
511 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
512 assert!(
513 warnings[0].message.contains("before"),
514 "Warning should be about blank line before"
515 );
516
517 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
520 let warnings = rule.check(&ctx).unwrap();
521 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
522 assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
523 assert!(
524 warnings[0].message.contains("after"),
525 "Warning should be about blank line after"
526 );
527
528 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
531 let warnings = rule.check(&ctx).unwrap();
532 assert_eq!(
533 warnings.len(),
534 2,
535 "Expected 2 warnings for missing blank lines before and after"
536 );
537 }
538
539 #[test]
540 fn test_nested_code_blocks() {
541 let rule = MD031BlanksAroundFences::default();
542
543 let content = r#"````markdown
545```
546content
547```
548````"#;
549 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
550 let warnings = rule.check(&ctx).unwrap();
551 assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
552
553 let fixed = rule.fix(&ctx).unwrap();
555 assert_eq!(fixed, content, "Fix should not modify nested code blocks");
556 }
557
558 #[test]
559 fn test_nested_code_blocks_complex() {
560 let rule = MD031BlanksAroundFences::default();
561
562 let content = r#"# Documentation
564
565## Examples
566
567````markdown
568```python
569def hello():
570 print("Hello, world!")
571```
572
573```javascript
574console.log("Hello, world!");
575```
576````
577
578More text here."#;
579
580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581 let warnings = rule.check(&ctx).unwrap();
582 assert_eq!(
583 warnings.len(),
584 0,
585 "Should not flag any issues in properly formatted nested code blocks"
586 );
587
588 let content_5 = r#"`````markdown
590````python
591```bash
592echo "nested"
593```
594````
595`````"#;
596
597 let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
598 let warnings_5 = rule.check(&ctx_5).unwrap();
599 assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
600 }
601
602 #[test]
603 fn test_fix_preserves_trailing_newline() {
604 let rule = MD031BlanksAroundFences::default();
605
606 let content = "Some text\n```\ncode\n```\nMore text\n";
608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
609 let fixed = rule.fix(&ctx).unwrap();
610
611 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
613 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
614 }
615
616 #[test]
617 fn test_fix_preserves_no_trailing_newline() {
618 let rule = MD031BlanksAroundFences::default();
619
620 let content = "Some text\n```\ncode\n```\nMore text";
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623 let fixed = rule.fix(&ctx).unwrap();
624
625 assert!(
627 !fixed.ends_with('\n'),
628 "Fix should not add trailing newline if original didn't have one"
629 );
630 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
631 }
632
633 #[test]
634 fn test_list_items_config_true() {
635 let rule = MD031BlanksAroundFences::new(true);
637
638 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
639 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
640 let warnings = rule.check(&ctx).unwrap();
641
642 assert_eq!(warnings.len(), 2);
644 assert!(warnings[0].message.contains("before"));
645 assert!(warnings[1].message.contains("after"));
646 }
647
648 #[test]
649 fn test_list_items_config_false() {
650 let rule = MD031BlanksAroundFences::new(false);
652
653 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
655 let warnings = rule.check(&ctx).unwrap();
656
657 assert_eq!(warnings.len(), 0);
659 }
660
661 #[test]
662 fn test_list_items_config_false_outside_list() {
663 let rule = MD031BlanksAroundFences::new(false);
665
666 let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
668 let warnings = rule.check(&ctx).unwrap();
669
670 assert_eq!(warnings.len(), 2);
672 assert!(warnings[0].message.contains("before"));
673 assert!(warnings[1].message.contains("after"));
674 }
675
676 #[test]
677 fn test_default_config_section() {
678 let rule = MD031BlanksAroundFences::default();
679 let config_section = rule.default_config_section();
680
681 assert!(config_section.is_some());
682 let (name, value) = config_section.unwrap();
683 assert_eq!(name, "MD031");
684
685 if let toml::Value::Table(table) = value {
687 assert!(table.contains_key("list-items"));
688 assert_eq!(table["list-items"], toml::Value::Boolean(true));
689 } else {
690 panic!("Expected TOML table");
691 }
692 }
693
694 #[test]
695 fn test_fix_list_items_config_false() {
696 let rule = MD031BlanksAroundFences::new(false);
698
699 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701 let fixed = rule.fix(&ctx).unwrap();
702
703 assert_eq!(fixed, content);
705 }
706
707 #[test]
708 fn test_fix_list_items_config_true() {
709 let rule = MD031BlanksAroundFences::new(true);
711
712 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714 let fixed = rule.fix(&ctx).unwrap();
715
716 let expected = "1. First item\n\n ```python\n code()\n ```\n\n2. Second item";
718 assert_eq!(fixed, expected);
719 }
720
721 #[test]
722 fn test_no_warning_after_frontmatter() {
723 let rule = MD031BlanksAroundFences::default();
726
727 let content = "---\ntitle: Test\n---\n```\ncode\n```";
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let warnings = rule.check(&ctx).unwrap();
730
731 assert!(
733 warnings.is_empty(),
734 "Expected no warnings for code block after frontmatter, got: {warnings:?}"
735 );
736 }
737
738 #[test]
739 fn test_fix_does_not_add_blank_after_frontmatter() {
740 let rule = MD031BlanksAroundFences::default();
742
743 let content = "---\ntitle: Test\n---\n```\ncode\n```";
744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
745 let fixed = rule.fix(&ctx).unwrap();
746
747 assert_eq!(fixed, content);
749 }
750
751 #[test]
752 fn test_frontmatter_with_blank_line_before_code() {
753 let rule = MD031BlanksAroundFences::default();
755
756 let content = "---\ntitle: Test\n---\n\n```\ncode\n```";
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758 let warnings = rule.check(&ctx).unwrap();
759
760 assert!(warnings.is_empty());
761 }
762
763 #[test]
764 fn test_no_warning_for_admonition_after_frontmatter() {
765 let rule = MD031BlanksAroundFences::default();
767
768 let content = "---\ntitle: Test\n---\n!!! note\n This is a note";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
770 let warnings = rule.check(&ctx).unwrap();
771
772 assert!(
773 warnings.is_empty(),
774 "Expected no warnings for admonition after frontmatter, got: {warnings:?}"
775 );
776 }
777
778 #[test]
779 fn test_toml_frontmatter_before_code() {
780 let rule = MD031BlanksAroundFences::default();
782
783 let content = "+++\ntitle = \"Test\"\n+++\n```\ncode\n```";
784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785 let warnings = rule.check(&ctx).unwrap();
786
787 assert!(
788 warnings.is_empty(),
789 "Expected no warnings for code block after TOML frontmatter, got: {warnings:?}"
790 );
791 }
792
793 #[test]
794 fn test_fenced_code_in_list_with_4_space_indent_issue_276() {
795 let rule = MD031BlanksAroundFences::new(true);
799
800 let content =
802 "1. First item\n2. Second item with code:\n ```python\n print(\"Hello\")\n ```\n3. Third item";
803 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
804 let warnings = rule.check(&ctx).unwrap();
805
806 assert_eq!(
808 warnings.len(),
809 2,
810 "Should detect fenced code in list with 4-space indent, got: {warnings:?}"
811 );
812 assert!(warnings[0].message.contains("before"));
813 assert!(warnings[1].message.contains("after"));
814
815 let fixed = rule.fix(&ctx).unwrap();
817 let expected =
818 "1. First item\n2. Second item with code:\n\n ```python\n print(\"Hello\")\n ```\n\n3. Third item";
819 assert_eq!(
820 fixed, expected,
821 "Fix should add blank lines around list-indented fenced code"
822 );
823 }
824
825 #[test]
826 fn test_fenced_code_in_list_with_mixed_indentation() {
827 let rule = MD031BlanksAroundFences::new(true);
829
830 let content = r#"# Test
831
8323-space indent:
8331. First item
834 ```python
835 code
836 ```
8372. Second item
838
8394-space indent:
8401. First item
841 ```python
842 code
843 ```
8442. Second item"#;
845
846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
847 let warnings = rule.check(&ctx).unwrap();
848
849 assert_eq!(
851 warnings.len(),
852 4,
853 "Should detect all fenced code blocks regardless of indentation, got: {warnings:?}"
854 );
855 }
856
857 #[test]
858 fn test_fix_preserves_blockquote_prefix_before_fence() {
859 let rule = MD031BlanksAroundFences::default();
861
862 let content = "> Text before
863> ```
864> code
865> ```";
866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867 let fixed = rule.fix(&ctx).unwrap();
868
869 let expected = "> Text before
871>
872> ```
873> code
874> ```";
875 assert_eq!(
876 fixed, expected,
877 "Fix should insert '>' blank line, not plain blank line"
878 );
879 }
880
881 #[test]
882 fn test_fix_preserves_blockquote_prefix_after_fence() {
883 let rule = MD031BlanksAroundFences::default();
885
886 let content = "> ```
887> code
888> ```
889> Text after";
890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
891 let fixed = rule.fix(&ctx).unwrap();
892
893 let expected = "> ```
895> code
896> ```
897>
898> Text after";
899 assert_eq!(
900 fixed, expected,
901 "Fix should insert '>' blank line after fence, not plain blank line"
902 );
903 }
904
905 #[test]
906 fn test_fix_preserves_nested_blockquote_prefix() {
907 let rule = MD031BlanksAroundFences::default();
909
910 let content = ">> Nested quote
911>> ```
912>> code
913>> ```
914>> More text";
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916 let fixed = rule.fix(&ctx).unwrap();
917
918 let expected = ">> Nested quote
920>>
921>> ```
922>> code
923>> ```
924>>
925>> More text";
926 assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
927 }
928
929 #[test]
930 fn test_fix_preserves_triple_nested_blockquote_prefix() {
931 let rule = MD031BlanksAroundFences::default();
933
934 let content = ">>> Triple nested
935>>> ```
936>>> code
937>>> ```
938>>> More text";
939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940 let fixed = rule.fix(&ctx).unwrap();
941
942 let expected = ">>> Triple nested
943>>>
944>>> ```
945>>> code
946>>> ```
947>>>
948>>> More text";
949 assert_eq!(
950 fixed, expected,
951 "Fix should preserve triple-nested blockquote prefix '>>>'"
952 );
953 }
954}