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