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 pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
11use serde::{Deserialize, Serialize};
12
13/// Configuration for MD031 rule
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "kebab-case")]
16pub struct MD031Config {
17    /// Whether to require blank lines around code blocks in lists
18    #[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/// Rule MD031: Fenced code blocks should be surrounded by blank lines
39#[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_empty_line(line: &str) -> bool {
56        line.trim().is_empty()
57    }
58
59    /// Check if a line is inside a list item
60    fn is_in_list(&self, line_index: usize, lines: &[&str]) -> bool {
61        // Look backwards to find if we're in a list item
62        for i in (0..=line_index).rev() {
63            let line = lines[i];
64            let trimmed = line.trim_start();
65
66            // If we hit a blank line, we're no longer in a list
67            if trimmed.is_empty() {
68                return false;
69            }
70
71            // Check for ordered list (number followed by . or ))
72            if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
73                let mut chars = trimmed.chars().skip_while(|c| c.is_ascii_digit());
74                if let Some(next) = chars.next()
75                    && (next == '.' || next == ')')
76                    && chars.next() == Some(' ')
77                {
78                    return true;
79                }
80            }
81
82            // Check for unordered list
83            if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
84                return true;
85            }
86
87            // If this line is indented (3+ columns), it might be a continuation of a list item
88            let is_indented = ElementCache::calculate_indentation_width_default(line) >= 3;
89            if is_indented {
90                continue; // Keep looking backwards for the list marker
91            }
92
93            // If we reach here and haven't found a list marker, and we're not at an indented line,
94            // then we're not in a list
95            return false;
96        }
97
98        false
99    }
100
101    /// Check if blank line should be required based on configuration
102    fn should_require_blank_line(&self, line_index: usize, lines: &[&str]) -> bool {
103        if self.config.list_items {
104            // Always require blank lines when list_items is true
105            true
106        } else {
107            // Don't require blank lines inside lists when list_items is false
108            !self.is_in_list(line_index, lines)
109        }
110    }
111
112    /// Check if the current line is immediately after frontmatter (prev line is closing delimiter)
113    fn is_right_after_frontmatter(line_index: usize, ctx: &crate::lint_context::LintContext) -> bool {
114        line_index > 0
115            && ctx.lines.get(line_index - 1).is_some_and(|info| info.in_front_matter)
116            && ctx.lines.get(line_index).is_some_and(|info| !info.in_front_matter)
117    }
118
119    /// Detect fenced code blocks using pulldown-cmark (handles list-indented fences correctly)
120    ///
121    /// Returns a vector of (opening_line_idx, closing_line_idx) for each fenced code block.
122    /// The indices are 0-based line numbers.
123    fn detect_fenced_code_blocks_pulldown(
124        content: &str,
125        line_offsets: &[usize],
126        lines: &[&str],
127    ) -> Vec<(usize, usize)> {
128        let mut fenced_blocks = Vec::new();
129        let options = Options::all();
130        let parser = Parser::new_ext(content, options).into_offset_iter();
131
132        let mut current_block_start: Option<usize> = None;
133
134        // Helper to convert byte offset to line index
135        let byte_to_line = |byte_offset: usize| -> usize {
136            line_offsets
137                .iter()
138                .enumerate()
139                .rev()
140                .find(|&(_, &offset)| offset <= byte_offset)
141                .map(|(idx, _)| idx)
142                .unwrap_or(0)
143        };
144
145        for (event, range) in parser {
146            match event {
147                Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) => {
148                    let line_idx = byte_to_line(range.start);
149                    current_block_start = Some(line_idx);
150                }
151                Event::End(TagEnd::CodeBlock) => {
152                    if let Some(start_line) = current_block_start.take() {
153                        // Find the closing fence line
154                        // The range.end points just past the closing fence
155                        // We need to find the line that contains the actual closing fence
156                        let end_byte = if range.end > 0 { range.end - 1 } else { 0 };
157                        let end_line = byte_to_line(end_byte);
158
159                        // Verify this is actually a closing fence line (not just end of content)
160                        // For properly closed fences, the end line should contain a fence marker
161                        let end_line_content = lines.get(end_line).unwrap_or(&"");
162                        let trimmed = end_line_content.trim();
163                        let is_closing_fence = (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
164                            && trimmed
165                                .chars()
166                                .skip_while(|&c| c == '`' || c == '~')
167                                .all(|c| c.is_whitespace());
168
169                        if is_closing_fence {
170                            fenced_blocks.push((start_line, end_line));
171                        } else {
172                            // Unclosed code block - extends to end of document
173                            // We still record it but the end_line will be the last line
174                            fenced_blocks.push((start_line, lines.len().saturating_sub(1)));
175                        }
176                    }
177                }
178                _ => {}
179            }
180        }
181
182        // Handle any unclosed block
183        if let Some(start_line) = current_block_start {
184            fenced_blocks.push((start_line, lines.len().saturating_sub(1)));
185        }
186
187        fenced_blocks
188    }
189}
190
191impl Rule for MD031BlanksAroundFences {
192    fn name(&self) -> &'static str {
193        "MD031"
194    }
195
196    fn description(&self) -> &'static str {
197        "Fenced code blocks should be surrounded by blank lines"
198    }
199
200    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
201        let content = ctx.content;
202        let line_index = &ctx.line_index;
203
204        let mut warnings = Vec::new();
205        let lines: Vec<&str> = content.lines().collect();
206        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
207
208        // Detect fenced code blocks using pulldown-cmark (handles list-indented fences correctly)
209        let fenced_blocks = Self::detect_fenced_code_blocks_pulldown(content, &ctx.line_offsets, &lines);
210
211        // Check blank lines around each fenced code block
212        for (opening_line, closing_line) in &fenced_blocks {
213            // Check for blank line before opening fence
214            // Skip if right after frontmatter
215            if *opening_line > 0
216                && !Self::is_empty_line(lines[*opening_line - 1])
217                && !Self::is_right_after_frontmatter(*opening_line, ctx)
218                && self.should_require_blank_line(*opening_line, &lines)
219            {
220                let (start_line, start_col, end_line, end_col) =
221                    calculate_line_range(*opening_line + 1, lines[*opening_line]);
222
223                warnings.push(LintWarning {
224                    rule_name: Some(self.name().to_string()),
225                    line: start_line,
226                    column: start_col,
227                    end_line,
228                    end_column: end_col,
229                    message: "No blank line before fenced code block".to_string(),
230                    severity: Severity::Warning,
231                    fix: Some(Fix {
232                        range: line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
233                        replacement: "\n".to_string(),
234                    }),
235                });
236            }
237
238            // Check for blank line after closing fence
239            // Allow Kramdown block attributes if configured
240            if *closing_line + 1 < lines.len()
241                && !Self::is_empty_line(lines[*closing_line + 1])
242                && !is_kramdown_block_attribute(lines[*closing_line + 1])
243                && self.should_require_blank_line(*closing_line, &lines)
244            {
245                let (start_line, start_col, end_line, end_col) =
246                    calculate_line_range(*closing_line + 1, lines[*closing_line]);
247
248                warnings.push(LintWarning {
249                    rule_name: Some(self.name().to_string()),
250                    line: start_line,
251                    column: start_col,
252                    end_line,
253                    end_column: end_col,
254                    message: "No blank line after fenced code block".to_string(),
255                    severity: Severity::Warning,
256                    fix: Some(Fix {
257                        range: line_index.line_col_to_byte_range_with_length(
258                            *closing_line + 1,
259                            lines[*closing_line].len() + 1,
260                            0,
261                        ),
262                        replacement: "\n".to_string(),
263                    }),
264                });
265            }
266        }
267
268        // Handle MkDocs admonitions separately
269        if is_mkdocs {
270            let mut in_admonition = false;
271            let mut admonition_indent = 0;
272            let mut i = 0;
273
274            while i < lines.len() {
275                let line = lines[i];
276
277                // Skip if this line is inside a fenced code block
278                let in_fenced_block = fenced_blocks.iter().any(|(start, end)| i >= *start && i <= *end);
279                if in_fenced_block {
280                    i += 1;
281                    continue;
282                }
283
284                // Check for MkDocs admonition start
285                if mkdocs_admonitions::is_admonition_start(line) {
286                    // Check for blank line before admonition
287                    if i > 0
288                        && !Self::is_empty_line(lines[i - 1])
289                        && !Self::is_right_after_frontmatter(i, ctx)
290                        && self.should_require_blank_line(i, &lines)
291                    {
292                        let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
293
294                        warnings.push(LintWarning {
295                            rule_name: Some(self.name().to_string()),
296                            line: start_line,
297                            column: start_col,
298                            end_line,
299                            end_column: end_col,
300                            message: "No blank line before admonition block".to_string(),
301                            severity: Severity::Warning,
302                            fix: Some(Fix {
303                                range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
304                                replacement: "\n".to_string(),
305                            }),
306                        });
307                    }
308
309                    in_admonition = true;
310                    admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
311                    i += 1;
312                    continue;
313                }
314
315                // Check if we're exiting an admonition
316                if in_admonition
317                    && !line.trim().is_empty()
318                    && !mkdocs_admonitions::is_admonition_content(line, admonition_indent)
319                {
320                    in_admonition = false;
321
322                    // Check for blank line after admonition
323                    if !Self::is_empty_line(line) && self.should_require_blank_line(i - 1, &lines) {
324                        let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
325
326                        warnings.push(LintWarning {
327                            rule_name: Some(self.name().to_string()),
328                            line: start_line,
329                            column: start_col,
330                            end_line,
331                            end_column: end_col,
332                            message: "No blank line after admonition block".to_string(),
333                            severity: Severity::Warning,
334                            fix: Some(Fix {
335                                range: line_index.line_col_to_byte_range_with_length(i, 0, 0),
336                                replacement: "\n".to_string(),
337                            }),
338                        });
339                    }
340
341                    admonition_indent = 0;
342                }
343
344                i += 1;
345            }
346        }
347
348        Ok(warnings)
349    }
350
351    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
352        let content = ctx.content;
353
354        // Check if original content ended with newline
355        let had_trailing_newline = content.ends_with('\n');
356
357        let lines: Vec<&str> = content.lines().collect();
358
359        // Detect fenced code blocks using pulldown-cmark (handles list-indented fences correctly)
360        let fenced_blocks = Self::detect_fenced_code_blocks_pulldown(content, &ctx.line_offsets, &lines);
361
362        // Collect lines that need blank lines before/after
363        let mut needs_blank_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
364        let mut needs_blank_after: std::collections::HashSet<usize> = std::collections::HashSet::new();
365
366        for (opening_line, closing_line) in &fenced_blocks {
367            // Check if needs blank line before opening fence
368            if *opening_line > 0
369                && !Self::is_empty_line(lines[*opening_line - 1])
370                && !Self::is_right_after_frontmatter(*opening_line, ctx)
371                && self.should_require_blank_line(*opening_line, &lines)
372            {
373                needs_blank_before.insert(*opening_line);
374            }
375
376            // Check if needs blank line after closing fence
377            if *closing_line + 1 < lines.len()
378                && !Self::is_empty_line(lines[*closing_line + 1])
379                && !is_kramdown_block_attribute(lines[*closing_line + 1])
380                && self.should_require_blank_line(*closing_line, &lines)
381            {
382                needs_blank_after.insert(*closing_line);
383            }
384        }
385
386        // Build result with blank lines inserted as needed
387        let mut result = Vec::new();
388        for (i, line) in lines.iter().enumerate() {
389            // Add blank line before this line if needed
390            if needs_blank_before.contains(&i) {
391                result.push(String::new());
392            }
393
394            result.push((*line).to_string());
395
396            // Add blank line after this line if needed
397            if needs_blank_after.contains(&i) {
398                result.push(String::new());
399            }
400        }
401
402        let fixed = result.join("\n");
403
404        // Preserve original trailing newline if it existed
405        let final_result = if had_trailing_newline && !fixed.ends_with('\n') {
406            format!("{fixed}\n")
407        } else {
408            fixed
409        };
410
411        Ok(final_result)
412    }
413
414    /// Get the category of this rule for selective processing
415    fn category(&self) -> RuleCategory {
416        RuleCategory::CodeBlock
417    }
418
419    /// Check if this rule should be skipped
420    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
421        // Check for fenced code blocks (backticks or tildes)
422        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
423    }
424
425    fn as_any(&self) -> &dyn std::any::Any {
426        self
427    }
428
429    fn default_config_section(&self) -> Option<(String, toml::Value)> {
430        let default_config = MD031Config::default();
431        let json_value = serde_json::to_value(&default_config).ok()?;
432        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
433        if let toml::Value::Table(table) = toml_value {
434            if !table.is_empty() {
435                Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
436            } else {
437                None
438            }
439        } else {
440            None
441        }
442    }
443
444    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
445    where
446        Self: Sized,
447    {
448        let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
449        Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use crate::lint_context::LintContext;
457
458    #[test]
459    fn test_basic_functionality() {
460        let rule = MD031BlanksAroundFences::default();
461
462        // Test with properly formatted code blocks
463        let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
464        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
465        let warnings = rule.check(&ctx).unwrap();
466        assert!(
467            warnings.is_empty(),
468            "Expected no warnings for properly formatted code blocks"
469        );
470
471        // Test with missing blank line before
472        let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
473        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474        let warnings = rule.check(&ctx).unwrap();
475        assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
476        assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
477        assert!(
478            warnings[0].message.contains("before"),
479            "Warning should be about blank line before"
480        );
481
482        // Test with missing blank line after
483        let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
484        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485        let warnings = rule.check(&ctx).unwrap();
486        assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
487        assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
488        assert!(
489            warnings[0].message.contains("after"),
490            "Warning should be about blank line after"
491        );
492
493        // Test with missing blank lines both before and after
494        let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
495        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496        let warnings = rule.check(&ctx).unwrap();
497        assert_eq!(
498            warnings.len(),
499            2,
500            "Expected 2 warnings for missing blank lines before and after"
501        );
502    }
503
504    #[test]
505    fn test_nested_code_blocks() {
506        let rule = MD031BlanksAroundFences::default();
507
508        // Test that nested code blocks are not flagged
509        let content = r#"````markdown
510```
511content
512```
513````"#;
514        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
515        let warnings = rule.check(&ctx).unwrap();
516        assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
517
518        // Test that fixes don't corrupt nested blocks
519        let fixed = rule.fix(&ctx).unwrap();
520        assert_eq!(fixed, content, "Fix should not modify nested code blocks");
521    }
522
523    #[test]
524    fn test_nested_code_blocks_complex() {
525        let rule = MD031BlanksAroundFences::default();
526
527        // Test documentation example with nested code blocks
528        let content = r#"# Documentation
529
530## Examples
531
532````markdown
533```python
534def hello():
535    print("Hello, world!")
536```
537
538```javascript
539console.log("Hello, world!");
540```
541````
542
543More text here."#;
544
545        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
546        let warnings = rule.check(&ctx).unwrap();
547        assert_eq!(
548            warnings.len(),
549            0,
550            "Should not flag any issues in properly formatted nested code blocks"
551        );
552
553        // Test with 5-backtick outer block
554        let content_5 = r#"`````markdown
555````python
556```bash
557echo "nested"
558```
559````
560`````"#;
561
562        let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
563        let warnings_5 = rule.check(&ctx_5).unwrap();
564        assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
565    }
566
567    #[test]
568    fn test_fix_preserves_trailing_newline() {
569        let rule = MD031BlanksAroundFences::default();
570
571        // Test content with trailing newline
572        let content = "Some text\n```\ncode\n```\nMore text\n";
573        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574        let fixed = rule.fix(&ctx).unwrap();
575
576        // Should preserve the trailing newline
577        assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
578        assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
579    }
580
581    #[test]
582    fn test_fix_preserves_no_trailing_newline() {
583        let rule = MD031BlanksAroundFences::default();
584
585        // Test content without trailing newline
586        let content = "Some text\n```\ncode\n```\nMore text";
587        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
588        let fixed = rule.fix(&ctx).unwrap();
589
590        // Should not add trailing newline if original didn't have one
591        assert!(
592            !fixed.ends_with('\n'),
593            "Fix should not add trailing newline if original didn't have one"
594        );
595        assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
596    }
597
598    #[test]
599    fn test_list_items_config_true() {
600        // Test with list_items: true (default) - should require blank lines even in lists
601        let rule = MD031BlanksAroundFences::new(true);
602
603        let content = "1. First item\n   ```python\n   code_in_list()\n   ```\n2. Second item";
604        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605        let warnings = rule.check(&ctx).unwrap();
606
607        // Should flag missing blank lines before and after code block in list
608        assert_eq!(warnings.len(), 2);
609        assert!(warnings[0].message.contains("before"));
610        assert!(warnings[1].message.contains("after"));
611    }
612
613    #[test]
614    fn test_list_items_config_false() {
615        // Test with list_items: false - should NOT require blank lines in lists
616        let rule = MD031BlanksAroundFences::new(false);
617
618        let content = "1. First item\n   ```python\n   code_in_list()\n   ```\n2. Second item";
619        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620        let warnings = rule.check(&ctx).unwrap();
621
622        // Should not flag missing blank lines inside lists
623        assert_eq!(warnings.len(), 0);
624    }
625
626    #[test]
627    fn test_list_items_config_false_outside_list() {
628        // Test with list_items: false - should still require blank lines outside lists
629        let rule = MD031BlanksAroundFences::new(false);
630
631        let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
632        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633        let warnings = rule.check(&ctx).unwrap();
634
635        // Should still flag missing blank lines outside lists
636        assert_eq!(warnings.len(), 2);
637        assert!(warnings[0].message.contains("before"));
638        assert!(warnings[1].message.contains("after"));
639    }
640
641    #[test]
642    fn test_default_config_section() {
643        let rule = MD031BlanksAroundFences::default();
644        let config_section = rule.default_config_section();
645
646        assert!(config_section.is_some());
647        let (name, value) = config_section.unwrap();
648        assert_eq!(name, "MD031");
649
650        // Should contain the list_items option with default value true
651        if let toml::Value::Table(table) = value {
652            assert!(table.contains_key("list-items"));
653            assert_eq!(table["list-items"], toml::Value::Boolean(true));
654        } else {
655            panic!("Expected TOML table");
656        }
657    }
658
659    #[test]
660    fn test_fix_list_items_config_false() {
661        // Test that fix respects list_items: false configuration
662        let rule = MD031BlanksAroundFences::new(false);
663
664        let content = "1. First item\n   ```python\n   code()\n   ```\n2. Second item";
665        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666        let fixed = rule.fix(&ctx).unwrap();
667
668        // Should not add blank lines when list_items is false
669        assert_eq!(fixed, content);
670    }
671
672    #[test]
673    fn test_fix_list_items_config_true() {
674        // Test that fix respects list_items: true configuration
675        let rule = MD031BlanksAroundFences::new(true);
676
677        let content = "1. First item\n   ```python\n   code()\n   ```\n2. Second item";
678        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
679        let fixed = rule.fix(&ctx).unwrap();
680
681        // Should add blank lines when list_items is true
682        let expected = "1. First item\n\n   ```python\n   code()\n   ```\n\n2. Second item";
683        assert_eq!(fixed, expected);
684    }
685
686    #[test]
687    fn test_no_warning_after_frontmatter() {
688        // Code block immediately after frontmatter should not trigger MD031
689        // This matches markdownlint behavior
690        let rule = MD031BlanksAroundFences::default();
691
692        let content = "---\ntitle: Test\n---\n```\ncode\n```";
693        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694        let warnings = rule.check(&ctx).unwrap();
695
696        // Should not flag missing blank line before code block after frontmatter
697        assert!(
698            warnings.is_empty(),
699            "Expected no warnings for code block after frontmatter, got: {warnings:?}"
700        );
701    }
702
703    #[test]
704    fn test_fix_does_not_add_blank_after_frontmatter() {
705        // Fix should not add blank line between frontmatter and code block
706        let rule = MD031BlanksAroundFences::default();
707
708        let content = "---\ntitle: Test\n---\n```\ncode\n```";
709        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
710        let fixed = rule.fix(&ctx).unwrap();
711
712        // Should not add blank line after frontmatter
713        assert_eq!(fixed, content);
714    }
715
716    #[test]
717    fn test_frontmatter_with_blank_line_before_code() {
718        // If there's already a blank line between frontmatter and code, that's fine
719        let rule = MD031BlanksAroundFences::default();
720
721        let content = "---\ntitle: Test\n---\n\n```\ncode\n```";
722        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723        let warnings = rule.check(&ctx).unwrap();
724
725        assert!(warnings.is_empty());
726    }
727
728    #[test]
729    fn test_no_warning_for_admonition_after_frontmatter() {
730        // Admonition immediately after frontmatter should not trigger MD031
731        let rule = MD031BlanksAroundFences::default();
732
733        let content = "---\ntitle: Test\n---\n!!! note\n    This is a note";
734        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
735        let warnings = rule.check(&ctx).unwrap();
736
737        assert!(
738            warnings.is_empty(),
739            "Expected no warnings for admonition after frontmatter, got: {warnings:?}"
740        );
741    }
742
743    #[test]
744    fn test_toml_frontmatter_before_code() {
745        // TOML frontmatter should also be handled
746        let rule = MD031BlanksAroundFences::default();
747
748        let content = "+++\ntitle = \"Test\"\n+++\n```\ncode\n```";
749        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750        let warnings = rule.check(&ctx).unwrap();
751
752        assert!(
753            warnings.is_empty(),
754            "Expected no warnings for code block after TOML frontmatter, got: {warnings:?}"
755        );
756    }
757
758    #[test]
759    fn test_fenced_code_in_list_with_4_space_indent_issue_276() {
760        // Issue #276: Fenced code blocks inside lists with 4+ space indentation
761        // were not being detected because of the old 0-3 space CommonMark limit.
762        // Now we use pulldown-cmark which correctly handles list-indented fences.
763        let rule = MD031BlanksAroundFences::new(true);
764
765        // 4-space indented fenced code block in list (was not detected before fix)
766        let content =
767            "1. First item\n2. Second item with code:\n    ```python\n    print(\"Hello\")\n    ```\n3. Third item";
768        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769        let warnings = rule.check(&ctx).unwrap();
770
771        // Should detect missing blank lines around the code block
772        assert_eq!(
773            warnings.len(),
774            2,
775            "Should detect fenced code in list with 4-space indent, got: {warnings:?}"
776        );
777        assert!(warnings[0].message.contains("before"));
778        assert!(warnings[1].message.contains("after"));
779
780        // Test the fix adds blank lines
781        let fixed = rule.fix(&ctx).unwrap();
782        let expected =
783            "1. First item\n2. Second item with code:\n\n    ```python\n    print(\"Hello\")\n    ```\n\n3. Third item";
784        assert_eq!(
785            fixed, expected,
786            "Fix should add blank lines around list-indented fenced code"
787        );
788    }
789
790    #[test]
791    fn test_fenced_code_in_list_with_mixed_indentation() {
792        // Test both 3-space and 4-space indented fenced code blocks in same document
793        let rule = MD031BlanksAroundFences::new(true);
794
795        let content = r#"# Test
796
7973-space indent:
7981. First item
799   ```python
800   code
801   ```
8022. Second item
803
8044-space indent:
8051. First item
806    ```python
807    code
808    ```
8092. Second item"#;
810
811        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812        let warnings = rule.check(&ctx).unwrap();
813
814        // Should detect all 4 missing blank lines (2 per code block)
815        assert_eq!(
816            warnings.len(),
817            4,
818            "Should detect all fenced code blocks regardless of indentation, got: {warnings:?}"
819        );
820    }
821}