Skip to main content

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::calculate_indentation_width_default;
7use crate::utils::kramdown_utils::is_kramdown_block_attribute;
8use crate::utils::mkdocs_admonitions;
9use crate::utils::quarto_divs;
10use crate::utils::range_utils::calculate_line_range;
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    /// Check if a line is effectively empty (blank or an empty blockquote line like ">")
56    /// Uses the pre-computed blockquote info from LintContext for accurate detection
57    fn is_effectively_empty_line(line_idx: usize, lines: &[&str], ctx: &crate::lint_context::LintContext) -> bool {
58        let line = lines.get(line_idx).unwrap_or(&"");
59
60        // First check if it's a regular blank line
61        if line.trim().is_empty() {
62            return true;
63        }
64
65        // Check if this is an empty blockquote line (like ">", "> ", ">>", etc.)
66        if let Some(line_info) = ctx.lines.get(line_idx)
67            && let Some(ref bq) = line_info.blockquote
68        {
69            // If the blockquote content is empty, this is effectively a blank line
70            return bq.content.trim().is_empty();
71        }
72
73        false
74    }
75
76    /// Check if a line is inside a list item
77    fn is_in_list(&self, line_index: usize, lines: &[&str]) -> bool {
78        // Look backwards to find if we're in a list item
79        for i in (0..=line_index).rev() {
80            let line = lines[i];
81            let trimmed = line.trim_start();
82
83            // If we hit a blank line, we're no longer in a list
84            if trimmed.is_empty() {
85                return false;
86            }
87
88            // Check for ordered list (number followed by . or ))
89            if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
90                let mut chars = trimmed.chars().skip_while(char::is_ascii_digit);
91                if let Some(next) = chars.next()
92                    && (next == '.' || next == ')')
93                    && chars.next() == Some(' ')
94                {
95                    return true;
96                }
97            }
98
99            // Check for unordered list
100            if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
101                return true;
102            }
103
104            // If this line is indented (3+ columns), it might be a continuation of a list item
105            let is_indented = calculate_indentation_width_default(line) >= 3;
106            if is_indented {
107                continue; // Keep looking backwards for the list marker
108            }
109
110            // If we reach here and haven't found a list marker, and we're not at an indented line,
111            // then we're not in a list
112            return false;
113        }
114
115        false
116    }
117
118    /// Check if blank line should be required based on configuration
119    fn should_require_blank_line(&self, line_index: usize, lines: &[&str]) -> bool {
120        if self.config.list_items {
121            // Always require blank lines when list_items is true
122            true
123        } else {
124            // Don't require blank lines inside lists when list_items is false
125            !self.is_in_list(line_index, lines)
126        }
127    }
128
129    /// Check if the current line is immediately after frontmatter (prev line is closing delimiter)
130    fn is_right_after_frontmatter(line_index: usize, ctx: &crate::lint_context::LintContext) -> bool {
131        line_index > 0
132            && ctx.lines.get(line_index - 1).is_some_and(|info| info.in_front_matter)
133            && ctx.lines.get(line_index).is_some_and(|info| !info.in_front_matter)
134    }
135
136    /// Derive fenced code block line ranges from pre-computed code_block_details.
137    ///
138    /// Returns a vector of (opening_line_idx, closing_line_idx) for each fenced code block.
139    /// The indices are 0-based line numbers.
140    fn fenced_block_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
141        let lines = ctx.raw_lines();
142
143        ctx.code_block_details
144            .iter()
145            .filter(|d| d.is_fenced)
146            .map(|detail| {
147                // Convert start byte offset to line index
148                let start_line = ctx
149                    .line_offsets
150                    .partition_point(|&off| off <= detail.start)
151                    .saturating_sub(1);
152
153                // Convert end byte offset to line index
154                let end_byte = if detail.end > 0 { detail.end - 1 } else { 0 };
155                let end_line = ctx
156                    .line_offsets
157                    .partition_point(|&off| off <= end_byte)
158                    .saturating_sub(1);
159
160                // Verify this is actually a closing fence line (not just end of content)
161                let end_line_content = lines.get(end_line).unwrap_or(&"");
162                let trimmed = end_line_content.trim();
163                let content_after_bq = if trimmed.starts_with('>') {
164                    trimmed.trim_start_matches(['>', ' ']).trim()
165                } else {
166                    trimmed
167                };
168                let is_closing_fence = (content_after_bq.starts_with("```") || content_after_bq.starts_with("~~~"))
169                    && content_after_bq
170                        .chars()
171                        .skip_while(|&c| c == '`' || c == '~')
172                        .all(char::is_whitespace);
173
174                if is_closing_fence {
175                    (start_line, end_line)
176                } else {
177                    (start_line, lines.len().saturating_sub(1))
178                }
179            })
180            .collect()
181    }
182}
183
184impl Rule for MD031BlanksAroundFences {
185    fn name(&self) -> &'static str {
186        "MD031"
187    }
188
189    fn description(&self) -> &'static str {
190        "Fenced code blocks should be surrounded by blank lines"
191    }
192
193    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
194        let line_index = &ctx.line_index;
195
196        let mut warnings = Vec::new();
197        let lines = ctx.raw_lines();
198        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
199        let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
200
201        // Detect fenced code blocks using pulldown-cmark (handles list-indented fences correctly)
202        let fenced_blocks = Self::fenced_block_line_ranges(ctx);
203
204        // Helper to check if a line is a Quarto div marker (opening or closing)
205        let is_quarto_div_marker =
206            |line: &str| -> bool { is_quarto && (quarto_divs::is_div_open(line) || quarto_divs::is_div_close(line)) };
207
208        // Check blank lines around each fenced code block
209        for (opening_line, closing_line) in &fenced_blocks {
210            // Skip fenced code blocks inside PyMdown blocks
211            if ctx
212                .line_info(*opening_line + 1)
213                .is_some_and(|info| info.in_pymdown_block)
214            {
215                continue;
216            }
217
218            // Check for blank line before opening fence
219            // Skip if right after frontmatter
220            // Skip if right after Quarto div marker (Quarto flavor)
221            // Use is_effectively_empty_line to handle blockquote blank lines (issue #284)
222            let prev_line_is_quarto_marker = *opening_line > 0 && is_quarto_div_marker(lines[*opening_line - 1]);
223            if *opening_line > 0
224                && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
225                && !Self::is_right_after_frontmatter(*opening_line, ctx)
226                && !prev_line_is_quarto_marker
227                && self.should_require_blank_line(*opening_line, lines)
228            {
229                let (start_line, start_col, end_line, end_col) =
230                    calculate_line_range(*opening_line + 1, lines[*opening_line]);
231
232                let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
233                warnings.push(LintWarning {
234                    rule_name: Some(self.name().to_string()),
235                    line: start_line,
236                    column: start_col,
237                    end_line,
238                    end_column: end_col,
239                    message: "No blank line before fenced code block".to_string(),
240                    severity: Severity::Warning,
241                    fix: Some(Fix::new(
242                        line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
243                        format!("{bq_prefix}\n"),
244                    )),
245                });
246            }
247
248            // Check for blank line after closing fence
249            // Allow Kramdown block attributes if configured
250            // Skip if followed by Quarto div marker (Quarto flavor)
251            // Use is_effectively_empty_line to handle blockquote blank lines (issue #284)
252            let next_line_is_quarto_marker =
253                *closing_line + 1 < lines.len() && is_quarto_div_marker(lines[*closing_line + 1]);
254            if *closing_line + 1 < lines.len()
255                && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
256                && !is_kramdown_block_attribute(lines[*closing_line + 1])
257                && !next_line_is_quarto_marker
258                && self.should_require_blank_line(*closing_line, lines)
259            {
260                let (start_line, start_col, end_line, end_col) =
261                    calculate_line_range(*closing_line + 1, lines[*closing_line]);
262
263                let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
264                warnings.push(LintWarning {
265                    rule_name: Some(self.name().to_string()),
266                    line: start_line,
267                    column: start_col,
268                    end_line,
269                    end_column: end_col,
270                    message: "No blank line after fenced code block".to_string(),
271                    severity: Severity::Warning,
272                    fix: Some(Fix::new(
273                        line_index.line_col_to_byte_range_with_length(*closing_line + 2, 1, 0),
274                        format!("{bq_prefix}\n"),
275                    )),
276                });
277            }
278        }
279
280        // Handle MkDocs admonitions separately
281        if is_mkdocs {
282            let mut in_admonition = false;
283            let mut admonition_indent = 0;
284            let mut i = 0;
285
286            while i < lines.len() {
287                let line = lines[i];
288
289                // Skip if this line is inside a fenced code block
290                let in_fenced_block = fenced_blocks.iter().any(|(start, end)| i >= *start && i <= *end);
291                if in_fenced_block {
292                    i += 1;
293                    continue;
294                }
295
296                // Skip if this line is inside a PyMdown block
297                if ctx.line_info(i + 1).is_some_and(|info| info.in_pymdown_block) {
298                    i += 1;
299                    continue;
300                }
301
302                // Check for MkDocs admonition start
303                if mkdocs_admonitions::is_admonition_start(line) {
304                    // Check for blank line before admonition
305                    if i > 0
306                        && !Self::is_effectively_empty_line(i - 1, lines, ctx)
307                        && !Self::is_right_after_frontmatter(i, ctx)
308                        && self.should_require_blank_line(i, lines)
309                    {
310                        let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
311
312                        let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
313                        warnings.push(LintWarning {
314                            rule_name: Some(self.name().to_string()),
315                            line: start_line,
316                            column: start_col,
317                            end_line,
318                            end_column: end_col,
319                            message: "No blank line before admonition block".to_string(),
320                            severity: Severity::Warning,
321                            fix: Some(Fix::new(
322                                line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
323                                format!("{bq_prefix}\n"),
324                            )),
325                        });
326                    }
327
328                    in_admonition = true;
329                    admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
330                    i += 1;
331                    continue;
332                }
333
334                // Check if we're exiting an admonition
335                if in_admonition
336                    && !line.trim().is_empty()
337                    && !mkdocs_admonitions::is_admonition_content(line, admonition_indent)
338                {
339                    in_admonition = false;
340
341                    // Check for blank line after admonition
342                    // We need a blank line between the admonition content and the current line
343                    // Check if the previous line (i-1) is a blank line separator
344                    if i > 0
345                        && !Self::is_effectively_empty_line(i - 1, lines, ctx)
346                        && self.should_require_blank_line(i - 1, lines)
347                    {
348                        let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
349
350                        let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
351                        warnings.push(LintWarning {
352                            rule_name: Some(self.name().to_string()),
353                            line: start_line,
354                            column: start_col,
355                            end_line,
356                            end_column: end_col,
357                            message: "No blank line after admonition block".to_string(),
358                            severity: Severity::Warning,
359                            fix: Some(Fix::new(
360                                line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
361                                format!("{bq_prefix}\n"),
362                            )),
363                        });
364                    }
365
366                    admonition_indent = 0;
367                }
368
369                i += 1;
370            }
371        }
372
373        Ok(warnings)
374    }
375
376    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
377        if self.should_skip(ctx) {
378            return Ok(ctx.content.to_string());
379        }
380        let warnings = self.check(ctx)?;
381        if warnings.is_empty() {
382            return Ok(ctx.content.to_string());
383        }
384        let warnings =
385            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
386        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
387            .map_err(crate::rule::LintError::InvalidInput)
388    }
389
390    /// Get the category of this rule for selective processing
391    fn category(&self) -> RuleCategory {
392        RuleCategory::CodeBlock
393    }
394
395    /// Check if this rule should be skipped
396    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
397        if ctx.content.is_empty() {
398            return true;
399        }
400        let has_fences = ctx.likely_has_code() || ctx.has_char('~');
401        let has_mkdocs_admonitions = ctx.flavor == crate::config::MarkdownFlavor::MkDocs && ctx.content.contains("!!!");
402        !has_fences && !has_mkdocs_admonitions
403    }
404
405    fn as_any(&self) -> &dyn std::any::Any {
406        self
407    }
408
409    fn default_config_section(&self) -> Option<(String, toml::Value)> {
410        let default_config = MD031Config::default();
411        let json_value = serde_json::to_value(&default_config).ok()?;
412        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
413        if let toml::Value::Table(table) = toml_value {
414            if !table.is_empty() {
415                Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
416            } else {
417                None
418            }
419        } else {
420            None
421        }
422    }
423
424    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
425    where
426        Self: Sized,
427    {
428        let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
429        Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::lint_context::LintContext;
437
438    #[test]
439    fn test_basic_functionality() {
440        let rule = MD031BlanksAroundFences::default();
441
442        // Test with properly formatted code blocks
443        let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
444        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
445        let warnings = rule.check(&ctx).unwrap();
446        assert!(
447            warnings.is_empty(),
448            "Expected no warnings for properly formatted code blocks"
449        );
450
451        // Test with missing blank line before
452        let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
453        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
454        let warnings = rule.check(&ctx).unwrap();
455        assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
456        assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
457        assert!(
458            warnings[0].message.contains("before"),
459            "Warning should be about blank line before"
460        );
461
462        // Test with missing blank line after
463        let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
464        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
465        let warnings = rule.check(&ctx).unwrap();
466        assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
467        assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
468        assert!(
469            warnings[0].message.contains("after"),
470            "Warning should be about blank line after"
471        );
472
473        // Test with missing blank lines both before and after
474        let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
475        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
476        let warnings = rule.check(&ctx).unwrap();
477        assert_eq!(
478            warnings.len(),
479            2,
480            "Expected 2 warnings for missing blank lines before and after"
481        );
482    }
483
484    #[test]
485    fn test_nested_code_blocks() {
486        let rule = MD031BlanksAroundFences::default();
487
488        // Test that nested code blocks are not flagged
489        let content = r#"````markdown
490```
491content
492```
493````"#;
494        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
495        let warnings = rule.check(&ctx).unwrap();
496        assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
497
498        // Test that fixes don't corrupt nested blocks
499        let fixed = rule.fix(&ctx).unwrap();
500        assert_eq!(fixed, content, "Fix should not modify nested code blocks");
501    }
502
503    #[test]
504    fn test_nested_code_blocks_complex() {
505        let rule = MD031BlanksAroundFences::default();
506
507        // Test documentation example with nested code blocks
508        let content = r#"# Documentation
509
510## Examples
511
512````markdown
513```python
514def hello():
515    print("Hello, world!")
516```
517
518```javascript
519console.log("Hello, world!");
520```
521````
522
523More text here."#;
524
525        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526        let warnings = rule.check(&ctx).unwrap();
527        assert_eq!(
528            warnings.len(),
529            0,
530            "Should not flag any issues in properly formatted nested code blocks"
531        );
532
533        // Test with 5-backtick outer block
534        let content_5 = r#"`````markdown
535````python
536```bash
537echo "nested"
538```
539````
540`````"#;
541
542        let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
543        let warnings_5 = rule.check(&ctx_5).unwrap();
544        assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
545    }
546
547    #[test]
548    fn test_fix_preserves_trailing_newline() {
549        let rule = MD031BlanksAroundFences::default();
550
551        // Test content with trailing newline
552        let content = "Some text\n```\ncode\n```\nMore text\n";
553        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554        let fixed = rule.fix(&ctx).unwrap();
555
556        // Should preserve the trailing newline
557        assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
558        assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
559    }
560
561    #[test]
562    fn test_fix_preserves_no_trailing_newline() {
563        let rule = MD031BlanksAroundFences::default();
564
565        // Test content without trailing newline
566        let content = "Some text\n```\ncode\n```\nMore text";
567        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
568        let fixed = rule.fix(&ctx).unwrap();
569
570        // Should not add trailing newline if original didn't have one
571        assert!(
572            !fixed.ends_with('\n'),
573            "Fix should not add trailing newline if original didn't have one"
574        );
575        assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
576    }
577
578    #[test]
579    fn test_list_items_config_true() {
580        // Test with list_items: true (default) - should require blank lines even in lists
581        let rule = MD031BlanksAroundFences::new(true);
582
583        let content = "1. First item\n   ```python\n   code_in_list()\n   ```\n2. Second item";
584        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585        let warnings = rule.check(&ctx).unwrap();
586
587        // Should flag missing blank lines before and after code block in list
588        assert_eq!(warnings.len(), 2);
589        assert!(warnings[0].message.contains("before"));
590        assert!(warnings[1].message.contains("after"));
591    }
592
593    #[test]
594    fn test_list_items_config_false() {
595        // Test with list_items: false - should NOT require blank lines in lists
596        let rule = MD031BlanksAroundFences::new(false);
597
598        let content = "1. First item\n   ```python\n   code_in_list()\n   ```\n2. Second item";
599        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600        let warnings = rule.check(&ctx).unwrap();
601
602        // Should not flag missing blank lines inside lists
603        assert_eq!(warnings.len(), 0);
604    }
605
606    #[test]
607    fn test_list_items_config_false_outside_list() {
608        // Test with list_items: false - should still require blank lines outside lists
609        let rule = MD031BlanksAroundFences::new(false);
610
611        let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
612        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613        let warnings = rule.check(&ctx).unwrap();
614
615        // Should still flag missing blank lines outside lists
616        assert_eq!(warnings.len(), 2);
617        assert!(warnings[0].message.contains("before"));
618        assert!(warnings[1].message.contains("after"));
619    }
620
621    #[test]
622    fn test_default_config_section() {
623        let rule = MD031BlanksAroundFences::default();
624        let config_section = rule.default_config_section();
625
626        assert!(config_section.is_some());
627        let (name, value) = config_section.unwrap();
628        assert_eq!(name, "MD031");
629
630        // Should contain the list_items option with default value true
631        if let toml::Value::Table(table) = value {
632            assert!(table.contains_key("list-items"));
633            assert_eq!(table["list-items"], toml::Value::Boolean(true));
634        } else {
635            panic!("Expected TOML table");
636        }
637    }
638
639    #[test]
640    fn test_fix_list_items_config_false() {
641        // Test that fix respects list_items: false configuration
642        let rule = MD031BlanksAroundFences::new(false);
643
644        let content = "1. First item\n   ```python\n   code()\n   ```\n2. Second item";
645        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646        let fixed = rule.fix(&ctx).unwrap();
647
648        // Should not add blank lines when list_items is false
649        assert_eq!(fixed, content);
650    }
651
652    #[test]
653    fn test_fix_list_items_config_true() {
654        // Test that fix respects list_items: true configuration
655        let rule = MD031BlanksAroundFences::new(true);
656
657        let content = "1. First item\n   ```python\n   code()\n   ```\n2. Second item";
658        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659        let fixed = rule.fix(&ctx).unwrap();
660
661        // Should add blank lines when list_items is true
662        let expected = "1. First item\n\n   ```python\n   code()\n   ```\n\n2. Second item";
663        assert_eq!(fixed, expected);
664    }
665
666    #[test]
667    fn test_no_warning_after_frontmatter() {
668        // Code block immediately after frontmatter should not trigger MD031
669        // This matches markdownlint behavior
670        let rule = MD031BlanksAroundFences::default();
671
672        let content = "---\ntitle: Test\n---\n```\ncode\n```";
673        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
674        let warnings = rule.check(&ctx).unwrap();
675
676        // Should not flag missing blank line before code block after frontmatter
677        assert!(
678            warnings.is_empty(),
679            "Expected no warnings for code block after frontmatter, got: {warnings:?}"
680        );
681    }
682
683    #[test]
684    fn test_fix_does_not_add_blank_after_frontmatter() {
685        // Fix should not add blank line between frontmatter and code block
686        let rule = MD031BlanksAroundFences::default();
687
688        let content = "---\ntitle: Test\n---\n```\ncode\n```";
689        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690        let fixed = rule.fix(&ctx).unwrap();
691
692        // Should not add blank line after frontmatter
693        assert_eq!(fixed, content);
694    }
695
696    #[test]
697    fn test_frontmatter_with_blank_line_before_code() {
698        // If there's already a blank line between frontmatter and code, that's fine
699        let rule = MD031BlanksAroundFences::default();
700
701        let content = "---\ntitle: Test\n---\n\n```\ncode\n```";
702        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703        let warnings = rule.check(&ctx).unwrap();
704
705        assert!(warnings.is_empty());
706    }
707
708    #[test]
709    fn test_no_warning_for_admonition_after_frontmatter() {
710        // Admonition immediately after frontmatter should not trigger MD031
711        let rule = MD031BlanksAroundFences::default();
712
713        let content = "---\ntitle: Test\n---\n!!! note\n    This is a note";
714        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
715        let warnings = rule.check(&ctx).unwrap();
716
717        assert!(
718            warnings.is_empty(),
719            "Expected no warnings for admonition after frontmatter, got: {warnings:?}"
720        );
721    }
722
723    #[test]
724    fn test_toml_frontmatter_before_code() {
725        // TOML frontmatter should also be handled
726        let rule = MD031BlanksAroundFences::default();
727
728        let content = "+++\ntitle = \"Test\"\n+++\n```\ncode\n```";
729        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730        let warnings = rule.check(&ctx).unwrap();
731
732        assert!(
733            warnings.is_empty(),
734            "Expected no warnings for code block after TOML frontmatter, got: {warnings:?}"
735        );
736    }
737
738    #[test]
739    fn test_fenced_code_in_list_with_4_space_indent_issue_276() {
740        // Issue #276: Fenced code blocks inside lists with 4+ space indentation
741        // were not being detected because of the old 0-3 space CommonMark limit.
742        // Now we use pulldown-cmark which correctly handles list-indented fences.
743        let rule = MD031BlanksAroundFences::new(true);
744
745        // 4-space indented fenced code block in list (was not detected before fix)
746        let content =
747            "1. First item\n2. Second item with code:\n    ```python\n    print(\"Hello\")\n    ```\n3. Third item";
748        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749        let warnings = rule.check(&ctx).unwrap();
750
751        // Should detect missing blank lines around the code block
752        assert_eq!(
753            warnings.len(),
754            2,
755            "Should detect fenced code in list with 4-space indent, got: {warnings:?}"
756        );
757        assert!(warnings[0].message.contains("before"));
758        assert!(warnings[1].message.contains("after"));
759
760        // Test the fix adds blank lines
761        let fixed = rule.fix(&ctx).unwrap();
762        let expected =
763            "1. First item\n2. Second item with code:\n\n    ```python\n    print(\"Hello\")\n    ```\n\n3. Third item";
764        assert_eq!(
765            fixed, expected,
766            "Fix should add blank lines around list-indented fenced code"
767        );
768    }
769
770    #[test]
771    fn test_fenced_code_in_list_with_mixed_indentation() {
772        // Test both 3-space and 4-space indented fenced code blocks in same document
773        let rule = MD031BlanksAroundFences::new(true);
774
775        let content = r#"# Test
776
7773-space indent:
7781. First item
779   ```python
780   code
781   ```
7822. Second item
783
7844-space indent:
7851. First item
786    ```python
787    code
788    ```
7892. Second item"#;
790
791        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
792        let warnings = rule.check(&ctx).unwrap();
793
794        // Should detect all 4 missing blank lines (2 per code block)
795        assert_eq!(
796            warnings.len(),
797            4,
798            "Should detect all fenced code blocks regardless of indentation, got: {warnings:?}"
799        );
800    }
801
802    #[test]
803    fn test_fix_preserves_blockquote_prefix_before_fence() {
804        // Issue #268: Fix should insert blockquote-prefixed blank lines inside blockquotes
805        let rule = MD031BlanksAroundFences::default();
806
807        let content = "> Text before
808> ```
809> code
810> ```";
811        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812        let fixed = rule.fix(&ctx).unwrap();
813
814        // The blank line inserted before the fence should have the blockquote prefix
815        let expected = "> Text before
816>
817> ```
818> code
819> ```";
820        assert_eq!(
821            fixed, expected,
822            "Fix should insert '>' blank line, not plain blank line"
823        );
824    }
825
826    #[test]
827    fn test_fix_preserves_blockquote_prefix_after_fence() {
828        // Issue #268: Fix should insert blockquote-prefixed blank lines inside blockquotes
829        let rule = MD031BlanksAroundFences::default();
830
831        let content = "> ```
832> code
833> ```
834> Text after";
835        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
836        let fixed = rule.fix(&ctx).unwrap();
837
838        // The blank line inserted after the fence should have the blockquote prefix
839        let expected = "> ```
840> code
841> ```
842>
843> Text after";
844        assert_eq!(
845            fixed, expected,
846            "Fix should insert '>' blank line after fence, not plain blank line"
847        );
848    }
849
850    #[test]
851    fn test_fix_preserves_nested_blockquote_prefix() {
852        // Nested blockquotes should preserve the full prefix (e.g., ">>")
853        let rule = MD031BlanksAroundFences::default();
854
855        let content = ">> Nested quote
856>> ```
857>> code
858>> ```
859>> More text";
860        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
861        let fixed = rule.fix(&ctx).unwrap();
862
863        // Should insert ">>" blank lines, not ">" or plain
864        let expected = ">> Nested quote
865>>
866>> ```
867>> code
868>> ```
869>>
870>> More text";
871        assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
872    }
873
874    #[test]
875    fn test_fix_preserves_triple_nested_blockquote_prefix() {
876        // Triple-nested blockquotes should preserve full prefix
877        let rule = MD031BlanksAroundFences::default();
878
879        let content = ">>> Triple nested
880>>> ```
881>>> code
882>>> ```
883>>> More text";
884        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885        let fixed = rule.fix(&ctx).unwrap();
886
887        let expected = ">>> Triple nested
888>>>
889>>> ```
890>>> code
891>>> ```
892>>>
893>>> More text";
894        assert_eq!(
895            fixed, expected,
896            "Fix should preserve triple-nested blockquote prefix '>>>'"
897        );
898    }
899
900    // ==================== Quarto Flavor Tests ====================
901
902    #[test]
903    fn test_quarto_code_block_after_div_open() {
904        // Code block immediately after Quarto div opening should not require blank line
905        let rule = MD031BlanksAroundFences::default();
906        let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
907        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
908        let warnings = rule.check(&ctx).unwrap();
909        assert!(
910            warnings.is_empty(),
911            "Should not require blank line after Quarto div opening: {warnings:?}"
912        );
913    }
914
915    #[test]
916    fn test_quarto_code_block_before_div_close() {
917        // Code block immediately before Quarto div closing should not require blank line
918        let rule = MD031BlanksAroundFences::default();
919        let content = "::: {.callout-note}\nSome text\n```python\ncode\n```\n:::";
920        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
921        let warnings = rule.check(&ctx).unwrap();
922        // Should only warn about the blank before the code block (after "Some text"), not after
923        assert!(
924            warnings.len() <= 1,
925            "Should not require blank line before Quarto div closing: {warnings:?}"
926        );
927    }
928
929    #[test]
930    fn test_quarto_code_block_outside_div_still_requires_blanks() {
931        // Code block outside Quarto div should still require blank lines
932        let rule = MD031BlanksAroundFences::default();
933        let content = "Some text\n```python\ncode\n```\nMore text";
934        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
935        let warnings = rule.check(&ctx).unwrap();
936        assert_eq!(
937            warnings.len(),
938            2,
939            "Should still require blank lines around code blocks outside divs"
940        );
941    }
942
943    #[test]
944    fn test_quarto_code_block_with_callout_note() {
945        // Code block inside callout-note should work without blank lines at boundaries
946        let rule = MD031BlanksAroundFences::default();
947        let content = "::: {.callout-note}\n```r\n1 + 1\n```\n:::\n\nMore text";
948        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
949        let warnings = rule.check(&ctx).unwrap();
950        assert!(
951            warnings.is_empty(),
952            "Callout note with code block should have no warnings: {warnings:?}"
953        );
954    }
955
956    #[test]
957    fn test_quarto_nested_divs_with_code() {
958        // Nested divs with code blocks
959        let rule = MD031BlanksAroundFences::default();
960        let content = "::: {.outer}\n::: {.inner}\n```python\ncode\n```\n:::\n:::\n";
961        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
962        let warnings = rule.check(&ctx).unwrap();
963        assert!(
964            warnings.is_empty(),
965            "Nested divs with code blocks should have no warnings: {warnings:?}"
966        );
967    }
968
969    #[test]
970    fn test_quarto_div_markers_in_standard_flavor() {
971        // In standard flavor, ::: is not special, so normal rules apply
972        let rule = MD031BlanksAroundFences::default();
973        let content = "::: {.callout-note}\n```python\ncode\n```\n:::\n";
974        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975        let warnings = rule.check(&ctx).unwrap();
976        // In standard flavor, both before and after the code block need blank lines
977        // (unless the ":::" lines are treated as text and thus need blanks)
978        assert!(
979            !warnings.is_empty(),
980            "Standard flavor should require blanks around code blocks: {warnings:?}"
981        );
982    }
983
984    #[test]
985    fn test_quarto_fix_does_not_add_blanks_at_div_boundaries() {
986        // Fix should not add blank lines at div boundaries
987        let rule = MD031BlanksAroundFences::default();
988        let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
989        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
990        let fixed = rule.fix(&ctx).unwrap();
991        // Should remain unchanged - no blanks needed
992        assert_eq!(fixed, content, "Fix should not add blanks at Quarto div boundaries");
993    }
994
995    #[test]
996    fn test_quarto_code_block_with_content_before() {
997        // Code block with content before it (inside div) needs blank
998        let rule = MD031BlanksAroundFences::default();
999        let content = "::: {.callout-note}\nHere is some code:\n```python\ncode\n```\n:::";
1000        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1001        let warnings = rule.check(&ctx).unwrap();
1002        // Should warn about missing blank before code block (after "Here is some code:")
1003        assert_eq!(
1004            warnings.len(),
1005            1,
1006            "Should require blank before code block inside div: {warnings:?}"
1007        );
1008        assert!(warnings[0].message.contains("before"));
1009    }
1010
1011    #[test]
1012    fn test_quarto_code_block_with_content_after() {
1013        // Code block with content after it (inside div) needs blank
1014        let rule = MD031BlanksAroundFences::default();
1015        let content = "::: {.callout-note}\n```python\ncode\n```\nMore content here.\n:::";
1016        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1017        let warnings = rule.check(&ctx).unwrap();
1018        // Should warn about missing blank after code block (before "More content here.")
1019        assert_eq!(
1020            warnings.len(),
1021            1,
1022            "Should require blank after code block inside div: {warnings:?}"
1023        );
1024        assert!(warnings[0].message.contains("after"));
1025    }
1026}