rumdl_lib/rules/
md031_blanks_around_fences.rs

1/// Rule MD031: Blank lines around fenced code blocks
2///
3/// See [docs/md031.md](../../docs/md031.md) for full documentation, configuration, and examples.
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::document_structure::{DocumentStructure, DocumentStructureExtensions};
7use crate::utils::kramdown_utils::is_kramdown_block_attribute;
8use crate::utils::mkdocs_admonitions;
9use crate::utils::range_utils::{LineIndex, calculate_line_range};
10use serde::{Deserialize, Serialize};
11
12/// Configuration for MD031 rule
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "kebab-case")]
15pub struct MD031Config {
16    /// Whether to require blank lines around code blocks in lists
17    #[serde(default = "default_list_items")]
18    pub list_items: bool,
19}
20
21impl Default for MD031Config {
22    fn default() -> Self {
23        Self {
24            list_items: default_list_items(),
25        }
26    }
27}
28
29fn default_list_items() -> bool {
30    true
31}
32
33impl RuleConfig for MD031Config {
34    const RULE_NAME: &'static str = "MD031";
35}
36
37/// Rule MD031: Fenced code blocks should be surrounded by blank lines
38#[derive(Clone, Default)]
39pub struct MD031BlanksAroundFences {
40    config: MD031Config,
41}
42
43impl MD031BlanksAroundFences {
44    pub fn new(list_items: bool) -> Self {
45        Self {
46            config: MD031Config { list_items },
47        }
48    }
49
50    pub fn from_config_struct(config: MD031Config) -> Self {
51        Self { config }
52    }
53
54    fn is_empty_line(line: &str) -> bool {
55        line.trim().is_empty()
56    }
57
58    /// Check if a line is inside a list item
59    fn is_in_list(&self, line_index: usize, lines: &[&str]) -> bool {
60        // Look backwards to find if we're in a list item
61        for i in (0..=line_index).rev() {
62            let line = lines[i];
63            let trimmed = line.trim_start();
64
65            // If we hit a blank line, we're no longer in a list
66            if trimmed.is_empty() {
67                return false;
68            }
69
70            // Check for ordered list (number followed by . or ))
71            if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
72                let mut chars = trimmed.chars().skip_while(|c| c.is_ascii_digit());
73                if let Some(next) = chars.next()
74                    && (next == '.' || next == ')')
75                    && chars.next() == Some(' ')
76                {
77                    return true;
78                }
79            }
80
81            // Check for unordered list
82            if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
83                return true;
84            }
85
86            // If this line is indented, it might be a continuation of a list item
87            let is_indented = line.starts_with("    ") || line.starts_with("\t") || line.starts_with("   ");
88            if is_indented {
89                continue; // Keep looking backwards for the list marker
90            }
91
92            // If we reach here and haven't found a list marker, and we're not at an indented line,
93            // then we're not in a list
94            return false;
95        }
96
97        false
98    }
99
100    /// Check if blank line should be required based on configuration
101    fn should_require_blank_line(&self, line_index: usize, lines: &[&str]) -> bool {
102        if self.config.list_items {
103            // Always require blank lines when list_items is true
104            true
105        } else {
106            // Don't require blank lines inside lists when list_items is false
107            !self.is_in_list(line_index, lines)
108        }
109    }
110}
111
112impl Rule for MD031BlanksAroundFences {
113    fn name(&self) -> &'static str {
114        "MD031"
115    }
116
117    fn description(&self) -> &'static str {
118        "Fenced code blocks should be surrounded by blank lines"
119    }
120
121    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
122        let content = ctx.content;
123        let line_index = LineIndex::new(content.to_string());
124
125        let mut warnings = Vec::new();
126        let lines: Vec<&str> = content.lines().collect();
127
128        let mut in_code_block = false;
129        let mut current_fence_marker: Option<String> = None;
130        let mut in_admonition = false;
131        let mut admonition_indent = 0;
132        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
133        let mut i = 0;
134
135        while i < lines.len() {
136            let line = lines[i];
137            let trimmed = line.trim_start();
138
139            // Check for MkDocs admonition start
140            if is_mkdocs && mkdocs_admonitions::is_admonition_start(line) {
141                // Check for blank line before admonition (similar to code blocks)
142                if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
143                    let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
144
145                    warnings.push(LintWarning {
146                        rule_name: Some(self.name()),
147                        line: start_line,
148                        column: start_col,
149                        end_line,
150                        end_column: end_col,
151                        message: "No blank line before admonition block".to_string(),
152                        severity: Severity::Warning,
153                        fix: Some(Fix {
154                            range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
155                            replacement: "\n".to_string(),
156                        }),
157                    });
158                }
159
160                in_admonition = true;
161                admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
162                i += 1;
163                continue;
164            }
165
166            // Check if we're exiting an admonition
167            if in_admonition {
168                if !line.trim().is_empty() && !mkdocs_admonitions::is_admonition_content(line, admonition_indent) {
169                    // We've exited the admonition
170                    in_admonition = false;
171
172                    // Check for blank line after admonition (current line should be blank)
173                    if !Self::is_empty_line(line) && self.should_require_blank_line(i - 1, &lines) {
174                        let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
175
176                        warnings.push(LintWarning {
177                            rule_name: Some(self.name()),
178                            line: start_line,
179                            column: start_col,
180                            end_line,
181                            end_column: end_col,
182                            message: "No blank line after admonition block".to_string(),
183                            severity: Severity::Warning,
184                            fix: Some(Fix {
185                                range: line_index.line_col_to_byte_range_with_length(i, 0, 0),
186                                replacement: "\n".to_string(),
187                            }),
188                        });
189                    }
190
191                    admonition_indent = 0;
192                    // Don't continue - process this line normally
193                } else {
194                    // Still in admonition
195                    i += 1;
196                    continue;
197                }
198            }
199
200            // Determine fence marker if this is a fence line
201            let fence_marker = if trimmed.starts_with("```") {
202                let backtick_count = trimmed.chars().take_while(|&c| c == '`').count();
203                if backtick_count >= 3 {
204                    Some("`".repeat(backtick_count))
205                } else {
206                    None
207                }
208            } else if trimmed.starts_with("~~~") {
209                let tilde_count = trimmed.chars().take_while(|&c| c == '~').count();
210                if tilde_count >= 3 {
211                    Some("~".repeat(tilde_count))
212                } else {
213                    None
214                }
215            } else {
216                None
217            };
218
219            if let Some(fence_marker) = fence_marker {
220                if in_code_block {
221                    // We're inside a code block, check if this closes it
222                    if let Some(ref current_marker) = current_fence_marker {
223                        // A fence can only close a code block if:
224                        // 1. It has the same type of marker (backticks or tildes)
225                        // 2. It has at least as many markers as the opening fence
226                        // 3. It has no content after the fence marker
227                        let same_type = (current_marker.starts_with('`') && fence_marker.starts_with('`'))
228                            || (current_marker.starts_with('~') && fence_marker.starts_with('~'));
229
230                        if same_type
231                            && fence_marker.len() >= current_marker.len()
232                            && trimmed[fence_marker.len()..].trim().is_empty()
233                        {
234                            // This closes the current code block
235                            in_code_block = false;
236                            current_fence_marker = None;
237
238                            // Check for blank line after closing fence
239                            // Allow Kramdown block attributes if configured
240                            if i + 1 < lines.len()
241                                && !Self::is_empty_line(lines[i + 1])
242                                && !is_kramdown_block_attribute(lines[i + 1])
243                                && self.should_require_blank_line(i, &lines)
244                            {
245                                let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
246
247                                warnings.push(LintWarning {
248                                    rule_name: Some(self.name()),
249                                    line: start_line,
250                                    column: start_col,
251                                    end_line,
252                                    end_column: end_col,
253                                    message: "No blank line after fenced code block".to_string(),
254                                    severity: Severity::Warning,
255                                    fix: Some(Fix {
256                                        range: line_index.line_col_to_byte_range_with_length(
257                                            i + 1,
258                                            lines[i].len() + 1,
259                                            0,
260                                        ),
261                                        replacement: "\n".to_string(),
262                                    }),
263                                });
264                            }
265                        }
266                        // else: This is content inside a code block (shorter fence or different type), ignore
267                    }
268                } else {
269                    // We're outside a code block, this opens one
270                    in_code_block = true;
271                    current_fence_marker = Some(fence_marker);
272
273                    // Check for blank line before opening fence
274                    if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
275                        let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
276
277                        warnings.push(LintWarning {
278                            rule_name: Some(self.name()),
279                            line: start_line,
280                            column: start_col,
281                            end_line,
282                            end_column: end_col,
283                            message: "No blank line before fenced code block".to_string(),
284                            severity: Severity::Warning,
285                            fix: Some(Fix {
286                                range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
287                                replacement: "\n".to_string(),
288                            }),
289                        });
290                    }
291                }
292            }
293            // If we're inside a code block, ignore all content lines
294            i += 1;
295        }
296
297        Ok(warnings)
298    }
299
300    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
301        let content = ctx.content;
302        let _line_index = LineIndex::new(content.to_string());
303
304        // Check if original content ended with newline
305        let had_trailing_newline = content.ends_with('\n');
306
307        let lines: Vec<&str> = content.lines().collect();
308
309        let mut result = Vec::new();
310        let mut in_code_block = false;
311        let mut current_fence_marker: Option<String> = None;
312
313        let mut i = 0;
314
315        while i < lines.len() {
316            let line = lines[i];
317            let trimmed = line.trim_start();
318
319            // Determine fence marker if this is a fence line
320            let fence_marker = if trimmed.starts_with("```") {
321                let backtick_count = trimmed.chars().take_while(|&c| c == '`').count();
322                if backtick_count >= 3 {
323                    Some("`".repeat(backtick_count))
324                } else {
325                    None
326                }
327            } else if trimmed.starts_with("~~~") {
328                let tilde_count = trimmed.chars().take_while(|&c| c == '~').count();
329                if tilde_count >= 3 {
330                    Some("~".repeat(tilde_count))
331                } else {
332                    None
333                }
334            } else {
335                None
336            };
337
338            if let Some(fence_marker) = fence_marker {
339                if in_code_block {
340                    // We're inside a code block, check if this closes it
341                    if let Some(ref current_marker) = current_fence_marker {
342                        if trimmed.starts_with(current_marker) && trimmed[current_marker.len()..].trim().is_empty() {
343                            // This closes the current code block
344                            result.push(line.to_string());
345                            in_code_block = false;
346                            current_fence_marker = None;
347
348                            // Add blank line after closing fence if needed
349                            // Don't add if next line is a Kramdown block attribute
350                            if i + 1 < lines.len()
351                                && !Self::is_empty_line(lines[i + 1])
352                                && !is_kramdown_block_attribute(lines[i + 1])
353                                && self.should_require_blank_line(i, &lines)
354                            {
355                                result.push(String::new());
356                            }
357                        } else {
358                            // This is content inside a code block (different fence marker)
359                            result.push(line.to_string());
360                        }
361                    } else {
362                        // This shouldn't happen, but preserve as content
363                        result.push(line.to_string());
364                    }
365                } else {
366                    // We're outside a code block, this opens one
367                    in_code_block = true;
368                    current_fence_marker = Some(fence_marker);
369
370                    // Add blank line before fence if needed
371                    if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
372                        result.push(String::new());
373                    }
374
375                    // Add opening fence
376                    result.push(line.to_string());
377                }
378            } else if in_code_block {
379                // We're inside a code block, preserve content as-is
380                result.push(line.to_string());
381            } else {
382                // We're outside code blocks, normal processing
383                result.push(line.to_string());
384            }
385            i += 1;
386        }
387
388        let fixed = result.join("\n");
389
390        // Preserve original trailing newline if it existed
391        let final_result = if had_trailing_newline && !fixed.ends_with('\n') {
392            format!("{fixed}\n")
393        } else {
394            fixed
395        };
396
397        Ok(final_result)
398    }
399
400    /// Get the category of this rule for selective processing
401    fn category(&self) -> RuleCategory {
402        RuleCategory::CodeBlock
403    }
404
405    /// Check if this rule should be skipped
406    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
407        let content = ctx.content;
408        content.is_empty() || (!content.contains("```") && !content.contains("~~~"))
409    }
410
411    /// Optimized check using document structure
412    fn check_with_structure(
413        &self,
414        ctx: &crate::lint_context::LintContext,
415        structure: &DocumentStructure,
416    ) -> LintResult {
417        let content = ctx.content;
418        // Early return if no code blocks
419        if !self.has_relevant_elements(ctx, structure) {
420            return Ok(Vec::new());
421        }
422
423        let line_index = LineIndex::new(content.to_string());
424        let mut warnings = Vec::new();
425        let lines: Vec<&str> = content.lines().collect();
426
427        // Process each code fence start and end
428        for &start_line in &structure.fenced_code_block_starts {
429            let line_num = start_line;
430
431            // Check for blank line before fence
432            if line_num > 1
433                && !Self::is_empty_line(lines[line_num - 2])
434                && self.should_require_blank_line(line_num - 1, &lines)
435            {
436                // Calculate precise character range for the entire fence line that needs a blank line before it
437                let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, lines[line_num - 1]);
438
439                warnings.push(LintWarning {
440                    rule_name: Some(self.name()),
441                    line: start_line,
442                    column: start_col,
443                    end_line,
444                    end_column: end_col,
445                    message: "No blank line before fenced code block".to_string(),
446                    severity: Severity::Warning,
447                    fix: Some(Fix {
448                        range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
449                        replacement: "\n".to_string(),
450                    }),
451                });
452            }
453        }
454
455        for &end_line in &structure.fenced_code_block_ends {
456            let line_num = end_line;
457
458            // Check for blank line after fence
459            if line_num < lines.len()
460                && !Self::is_empty_line(lines[line_num])
461                && self.should_require_blank_line(line_num - 1, &lines)
462            {
463                // Calculate precise character range for the entire fence line that needs a blank line after it
464                let (start_line_fence, start_col_fence, end_line_fence, end_col_fence) =
465                    calculate_line_range(line_num, lines[line_num - 1]);
466
467                warnings.push(LintWarning {
468                    rule_name: Some(self.name()),
469                    line: start_line_fence,
470                    column: start_col_fence,
471                    end_line: end_line_fence,
472                    end_column: end_col_fence,
473                    message: "No blank line after fenced code block".to_string(),
474                    severity: Severity::Warning,
475                    fix: Some(Fix {
476                        range: line_index.line_col_to_byte_range_with_length(
477                            line_num,
478                            lines[line_num - 1].len() + 1,
479                            0,
480                        ),
481                        replacement: "\n".to_string(),
482                    }),
483                });
484            }
485        }
486
487        Ok(warnings)
488    }
489
490    fn as_any(&self) -> &dyn std::any::Any {
491        self
492    }
493
494    fn default_config_section(&self) -> Option<(String, toml::Value)> {
495        let default_config = MD031Config::default();
496        let json_value = serde_json::to_value(&default_config).ok()?;
497        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
498        if let toml::Value::Table(table) = toml_value {
499            if !table.is_empty() {
500                Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
501            } else {
502                None
503            }
504        } else {
505            None
506        }
507    }
508
509    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
510    where
511        Self: Sized,
512    {
513        let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
514        Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
515    }
516}
517
518impl DocumentStructureExtensions for MD031BlanksAroundFences {
519    fn has_relevant_elements(
520        &self,
521        _ctx: &crate::lint_context::LintContext,
522        doc_structure: &DocumentStructure,
523    ) -> bool {
524        !doc_structure.fenced_code_block_starts.is_empty() || !doc_structure.fenced_code_block_ends.is_empty()
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use crate::lint_context::LintContext;
532    use crate::utils::document_structure::document_structure_from_str;
533
534    #[test]
535    fn test_with_document_structure() {
536        let rule = MD031BlanksAroundFences::default();
537
538        // Test with properly formatted code blocks
539        let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
540        let structure = document_structure_from_str(content);
541        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
542        let warnings = rule.check_with_structure(&ctx, &structure).unwrap();
543        assert!(
544            warnings.is_empty(),
545            "Expected no warnings for properly formatted code blocks"
546        );
547
548        // Test with missing blank line before
549        let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
550        let structure = document_structure_from_str(content);
551        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
552        let warnings = rule.check_with_structure(&ctx, &structure).unwrap();
553        assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
554        assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
555        assert!(
556            warnings[0].message.contains("before"),
557            "Warning should be about blank line before"
558        );
559
560        // Test with missing blank line after
561        let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
562        let structure = document_structure_from_str(content);
563        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
564        let warnings = rule.check_with_structure(&ctx, &structure).unwrap();
565        assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
566        assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
567        assert!(
568            warnings[0].message.contains("after"),
569            "Warning should be about blank line after"
570        );
571
572        // Test with missing blank lines both before and after
573        let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
574        let structure = document_structure_from_str(content);
575        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
576        let warnings = rule.check_with_structure(&ctx, &structure).unwrap();
577        assert_eq!(
578            warnings.len(),
579            2,
580            "Expected 2 warnings for missing blank lines before and after"
581        );
582    }
583
584    #[test]
585    fn test_nested_code_blocks() {
586        let rule = MD031BlanksAroundFences::default();
587
588        // Test that nested code blocks are not flagged
589        let content = r#"````markdown
590```
591content
592```
593````"#;
594        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
595        let warnings = rule.check(&ctx).unwrap();
596        assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
597
598        // Test that fixes don't corrupt nested blocks
599        let fixed = rule.fix(&ctx).unwrap();
600        assert_eq!(fixed, content, "Fix should not modify nested code blocks");
601    }
602
603    #[test]
604    fn test_nested_code_blocks_complex() {
605        let rule = MD031BlanksAroundFences::default();
606
607        // Test documentation example with nested code blocks
608        let content = r#"# Documentation
609
610## Examples
611
612````markdown
613```python
614def hello():
615    print("Hello, world!")
616```
617
618```javascript
619console.log("Hello, world!");
620```
621````
622
623More text here."#;
624
625        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
626        let warnings = rule.check(&ctx).unwrap();
627        assert_eq!(
628            warnings.len(),
629            0,
630            "Should not flag any issues in properly formatted nested code blocks"
631        );
632
633        // Test with 5-backtick outer block
634        let content_5 = r#"`````markdown
635````python
636```bash
637echo "nested"
638```
639````
640`````"#;
641
642        let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard);
643        let warnings_5 = rule.check(&ctx_5).unwrap();
644        assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
645    }
646
647    #[test]
648    fn test_fix_preserves_trailing_newline() {
649        let rule = MD031BlanksAroundFences::default();
650
651        // Test content with trailing newline
652        let content = "Some text\n```\ncode\n```\nMore text\n";
653        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
654        let fixed = rule.fix(&ctx).unwrap();
655
656        // Should preserve the trailing newline
657        assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
658        assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
659    }
660
661    #[test]
662    fn test_fix_preserves_no_trailing_newline() {
663        let rule = MD031BlanksAroundFences::default();
664
665        // Test content without trailing newline
666        let content = "Some text\n```\ncode\n```\nMore text";
667        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
668        let fixed = rule.fix(&ctx).unwrap();
669
670        // Should not add trailing newline if original didn't have one
671        assert!(
672            !fixed.ends_with('\n'),
673            "Fix should not add trailing newline if original didn't have one"
674        );
675        assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
676    }
677
678    #[test]
679    fn test_list_items_config_true() {
680        // Test with list_items: true (default) - should require blank lines even in lists
681        let rule = MD031BlanksAroundFences::new(true);
682
683        let content = "1. First item\n   ```python\n   code_in_list()\n   ```\n2. Second item";
684        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
685        let warnings = rule.check(&ctx).unwrap();
686
687        // Should flag missing blank lines before and after code block in list
688        assert_eq!(warnings.len(), 2);
689        assert!(warnings[0].message.contains("before"));
690        assert!(warnings[1].message.contains("after"));
691    }
692
693    #[test]
694    fn test_list_items_config_false() {
695        // Test with list_items: false - should NOT require blank lines in lists
696        let rule = MD031BlanksAroundFences::new(false);
697
698        let content = "1. First item\n   ```python\n   code_in_list()\n   ```\n2. Second item";
699        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
700        let warnings = rule.check(&ctx).unwrap();
701
702        // Should not flag missing blank lines inside lists
703        assert_eq!(warnings.len(), 0);
704    }
705
706    #[test]
707    fn test_list_items_config_false_outside_list() {
708        // Test with list_items: false - should still require blank lines outside lists
709        let rule = MD031BlanksAroundFences::new(false);
710
711        let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
712        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
713        let warnings = rule.check(&ctx).unwrap();
714
715        // Should still flag missing blank lines outside lists
716        assert_eq!(warnings.len(), 2);
717        assert!(warnings[0].message.contains("before"));
718        assert!(warnings[1].message.contains("after"));
719    }
720
721    #[test]
722    fn test_default_config_section() {
723        let rule = MD031BlanksAroundFences::default();
724        let config_section = rule.default_config_section();
725
726        assert!(config_section.is_some());
727        let (name, value) = config_section.unwrap();
728        assert_eq!(name, "MD031");
729
730        // Should contain the list_items option with default value true
731        if let toml::Value::Table(table) = value {
732            assert!(table.contains_key("list-items"));
733            assert_eq!(table["list-items"], toml::Value::Boolean(true));
734        } else {
735            panic!("Expected TOML table");
736        }
737    }
738
739    #[test]
740    fn test_fix_list_items_config_false() {
741        // Test that fix respects list_items: false configuration
742        let rule = MD031BlanksAroundFences::new(false);
743
744        let content = "1. First item\n   ```python\n   code()\n   ```\n2. Second item";
745        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
746        let fixed = rule.fix(&ctx).unwrap();
747
748        // Should not add blank lines when list_items is false
749        assert_eq!(fixed, content);
750    }
751
752    #[test]
753    fn test_fix_list_items_config_true() {
754        // Test that fix respects list_items: true configuration
755        let rule = MD031BlanksAroundFences::new(true);
756
757        let content = "1. First item\n   ```python\n   code()\n   ```\n2. Second item";
758        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
759        let fixed = rule.fix(&ctx).unwrap();
760
761        // Should add blank lines when list_items is true
762        let expected = "1. First item\n\n   ```python\n   code()\n   ```\n\n2. Second item";
763        assert_eq!(fixed, expected);
764    }
765}