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::pandoc;
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    /// Convert colon fence byte ranges from LintContext into (opener_line, closer_line) pairs.
184    fn colon_fence_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
185        ctx.colon_fence_ranges()
186            .iter()
187            .map(|&(start, end)| {
188                let start_line = ctx.line_offsets.partition_point(|&off| off <= start).saturating_sub(1);
189                let end_byte = if end > 0 { end - 1 } else { 0 };
190                let end_line = ctx
191                    .line_offsets
192                    .partition_point(|&off| off <= end_byte)
193                    .saturating_sub(1);
194                (start_line, end_line)
195            })
196            .collect()
197    }
198}
199
200impl Rule for MD031BlanksAroundFences {
201    fn name(&self) -> &'static str {
202        "MD031"
203    }
204
205    fn description(&self) -> &'static str {
206        "Fenced code blocks should be surrounded by blank lines"
207    }
208
209    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
210        let line_index = &ctx.line_index;
211
212        let mut warnings = Vec::new();
213        let lines = ctx.raw_lines();
214        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
215        let is_pandoc = ctx.flavor.is_pandoc_compatible();
216
217        // Detect fenced code blocks using pulldown-cmark (handles list-indented fences correctly)
218        let fenced_blocks = Self::fenced_block_line_ranges(ctx);
219
220        // Helper to check if a line is a Pandoc/Quarto div marker (opening or closing)
221        let is_pandoc_div_marker =
222            |line: &str| -> bool { is_pandoc && (pandoc::is_div_open(line) || pandoc::is_div_close(line)) };
223
224        // Check blank lines around each fenced code block
225        for (opening_line, closing_line) in &fenced_blocks {
226            // Skip fenced code blocks inside PyMdown blocks
227            if ctx
228                .line_info(*opening_line + 1)
229                .is_some_and(|info| info.in_pymdown_block)
230            {
231                continue;
232            }
233
234            // Check for blank line before opening fence
235            // Skip if right after frontmatter
236            // Skip if right after a Pandoc/Quarto div marker in Pandoc-compatible flavor
237            // Use is_effectively_empty_line to handle blockquote blank lines (issue #284)
238            let prev_line_is_pandoc_marker = *opening_line > 0 && is_pandoc_div_marker(lines[*opening_line - 1]);
239            if *opening_line > 0
240                && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
241                && !Self::is_right_after_frontmatter(*opening_line, ctx)
242                && !prev_line_is_pandoc_marker
243                && self.should_require_blank_line(*opening_line, lines)
244            {
245                let (start_line, start_col, end_line, end_col) =
246                    calculate_line_range(*opening_line + 1, lines[*opening_line]);
247
248                let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
249                warnings.push(LintWarning {
250                    rule_name: Some(self.name().to_string()),
251                    line: start_line,
252                    column: start_col,
253                    end_line,
254                    end_column: end_col,
255                    message: "No blank line before fenced code block".to_string(),
256                    severity: Severity::Warning,
257                    fix: Some(Fix::new(
258                        line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
259                        format!("{bq_prefix}\n"),
260                    )),
261                });
262            }
263
264            // Check for blank line after closing fence
265            // Allow Kramdown block attributes if configured
266            // Skip if followed by a Pandoc/Quarto div marker in Pandoc-compatible flavor
267            // Use is_effectively_empty_line to handle blockquote blank lines (issue #284)
268            let next_line_is_pandoc_marker =
269                *closing_line + 1 < lines.len() && is_pandoc_div_marker(lines[*closing_line + 1]);
270            if *closing_line + 1 < lines.len()
271                && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
272                && !is_kramdown_block_attribute(lines[*closing_line + 1])
273                && !next_line_is_pandoc_marker
274                && self.should_require_blank_line(*closing_line, lines)
275            {
276                let (start_line, start_col, end_line, end_col) =
277                    calculate_line_range(*closing_line + 1, lines[*closing_line]);
278
279                let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
280                warnings.push(LintWarning {
281                    rule_name: Some(self.name().to_string()),
282                    line: start_line,
283                    column: start_col,
284                    end_line,
285                    end_column: end_col,
286                    message: "No blank line after fenced code block".to_string(),
287                    severity: Severity::Warning,
288                    fix: Some(Fix::new(
289                        line_index.line_col_to_byte_range_with_length(*closing_line + 2, 1, 0),
290                        format!("{bq_prefix}\n"),
291                    )),
292                });
293            }
294        }
295
296        // Enforce blank lines around Azure DevOps colon code fences.
297        if ctx.flavor.supports_colon_code_fences() {
298            let colon_blocks = Self::colon_fence_line_ranges(ctx);
299            for (opening_line, closing_line) in &colon_blocks {
300                // Check for blank line before opener
301                if *opening_line > 0
302                    && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
303                    && !Self::is_right_after_frontmatter(*opening_line, ctx)
304                    && self.should_require_blank_line(*opening_line, lines)
305                {
306                    let (start_line, start_col, end_line, end_col) =
307                        calculate_line_range(*opening_line + 1, lines[*opening_line]);
308                    let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
309                    warnings.push(LintWarning {
310                        rule_name: Some(self.name().to_string()),
311                        line: start_line,
312                        column: start_col,
313                        end_line,
314                        end_column: end_col,
315                        message: "No blank line before colon code fence".to_string(),
316                        severity: Severity::Warning,
317                        fix: Some(Fix::new(
318                            line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
319                            format!("{bq_prefix}\n"),
320                        )),
321                    });
322                }
323
324                // Check for blank line after closer
325                if *closing_line + 1 < lines.len()
326                    && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
327                    && self.should_require_blank_line(*closing_line, lines)
328                {
329                    let (start_line, start_col, end_line, end_col) =
330                        calculate_line_range(*closing_line + 1, lines[*closing_line]);
331                    let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
332                    warnings.push(LintWarning {
333                        rule_name: Some(self.name().to_string()),
334                        line: start_line,
335                        column: start_col,
336                        end_line,
337                        end_column: end_col,
338                        message: "No blank line after colon code fence".to_string(),
339                        severity: Severity::Warning,
340                        fix: Some(Fix::new(
341                            line_index.line_col_to_byte_range_with_length(*closing_line + 2, 1, 0),
342                            format!("{bq_prefix}\n"),
343                        )),
344                    });
345                }
346            }
347        }
348
349        // Handle MkDocs admonitions separately
350        if is_mkdocs {
351            let mut in_admonition = false;
352            let mut admonition_indent = 0;
353            let mut i = 0;
354
355            while i < lines.len() {
356                let line = lines[i];
357
358                // Skip if this line is inside a fenced code block
359                let in_fenced_block = fenced_blocks.iter().any(|(start, end)| i >= *start && i <= *end);
360                if in_fenced_block {
361                    i += 1;
362                    continue;
363                }
364
365                // Skip if this line is inside a PyMdown block
366                if ctx.line_info(i + 1).is_some_and(|info| info.in_pymdown_block) {
367                    i += 1;
368                    continue;
369                }
370
371                // Check for MkDocs admonition start
372                if mkdocs_admonitions::is_admonition_start(line) {
373                    // Check for blank line before admonition
374                    if i > 0
375                        && !Self::is_effectively_empty_line(i - 1, lines, ctx)
376                        && !Self::is_right_after_frontmatter(i, ctx)
377                        && self.should_require_blank_line(i, lines)
378                    {
379                        let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
380
381                        let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
382                        warnings.push(LintWarning {
383                            rule_name: Some(self.name().to_string()),
384                            line: start_line,
385                            column: start_col,
386                            end_line,
387                            end_column: end_col,
388                            message: "No blank line before admonition block".to_string(),
389                            severity: Severity::Warning,
390                            fix: Some(Fix::new(
391                                line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
392                                format!("{bq_prefix}\n"),
393                            )),
394                        });
395                    }
396
397                    in_admonition = true;
398                    admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
399                    i += 1;
400                    continue;
401                }
402
403                // Check if we're exiting an admonition
404                if in_admonition
405                    && !line.trim().is_empty()
406                    && !mkdocs_admonitions::is_admonition_content(line, admonition_indent)
407                {
408                    in_admonition = false;
409
410                    // Check for blank line after admonition
411                    // We need a blank line between the admonition content and the current line
412                    // Check if the previous line (i-1) is a blank line separator
413                    if i > 0
414                        && !Self::is_effectively_empty_line(i - 1, lines, ctx)
415                        && self.should_require_blank_line(i - 1, lines)
416                    {
417                        let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
418
419                        let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
420                        warnings.push(LintWarning {
421                            rule_name: Some(self.name().to_string()),
422                            line: start_line,
423                            column: start_col,
424                            end_line,
425                            end_column: end_col,
426                            message: "No blank line after admonition block".to_string(),
427                            severity: Severity::Warning,
428                            fix: Some(Fix::new(
429                                line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
430                                format!("{bq_prefix}\n"),
431                            )),
432                        });
433                    }
434
435                    admonition_indent = 0;
436                }
437
438                i += 1;
439            }
440        }
441
442        Ok(warnings)
443    }
444
445    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
446        if self.should_skip(ctx) {
447            return Ok(ctx.content.to_string());
448        }
449        let warnings = self.check(ctx)?;
450        if warnings.is_empty() {
451            return Ok(ctx.content.to_string());
452        }
453        let warnings =
454            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
455        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
456            .map_err(crate::rule::LintError::InvalidInput)
457    }
458
459    /// Get the category of this rule for selective processing
460    fn category(&self) -> RuleCategory {
461        RuleCategory::CodeBlock
462    }
463
464    /// Check if this rule should be skipped
465    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
466        if ctx.content.is_empty() {
467            return true;
468        }
469        let has_fences = ctx.likely_has_code() || ctx.has_char('~');
470        let has_mkdocs_admonitions = ctx.flavor == crate::config::MarkdownFlavor::MkDocs && ctx.content.contains("!!!");
471        let has_colon_fences = ctx.flavor.supports_colon_code_fences() && ctx.content.contains(":::");
472        !has_fences && !has_mkdocs_admonitions && !has_colon_fences
473    }
474
475    fn as_any(&self) -> &dyn std::any::Any {
476        self
477    }
478
479    fn default_config_section(&self) -> Option<(String, toml::Value)> {
480        let default_config = MD031Config::default();
481        let json_value = serde_json::to_value(&default_config).ok()?;
482        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
483        if let toml::Value::Table(table) = toml_value {
484            if !table.is_empty() {
485                Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
486            } else {
487                None
488            }
489        } else {
490            None
491        }
492    }
493
494    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
495    where
496        Self: Sized,
497    {
498        let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
499        Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use crate::lint_context::LintContext;
507
508    #[test]
509    fn test_basic_functionality() {
510        let rule = MD031BlanksAroundFences::default();
511
512        // Test with properly formatted code blocks
513        let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
514        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
515        let warnings = rule.check(&ctx).unwrap();
516        assert!(
517            warnings.is_empty(),
518            "Expected no warnings for properly formatted code blocks"
519        );
520
521        // Test with missing blank line before
522        let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
523        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524        let warnings = rule.check(&ctx).unwrap();
525        assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
526        assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
527        assert!(
528            warnings[0].message.contains("before"),
529            "Warning should be about blank line before"
530        );
531
532        // Test with missing blank line after
533        let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
534        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
535        let warnings = rule.check(&ctx).unwrap();
536        assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
537        assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
538        assert!(
539            warnings[0].message.contains("after"),
540            "Warning should be about blank line after"
541        );
542
543        // Test with missing blank lines both before and after
544        let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
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            2,
550            "Expected 2 warnings for missing blank lines before and after"
551        );
552    }
553
554    #[test]
555    fn test_nested_code_blocks() {
556        let rule = MD031BlanksAroundFences::default();
557
558        // Test that nested code blocks are not flagged
559        let content = r#"````markdown
560```
561content
562```
563````"#;
564        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565        let warnings = rule.check(&ctx).unwrap();
566        assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
567
568        // Test that fixes don't corrupt nested blocks
569        let fixed = rule.fix(&ctx).unwrap();
570        assert_eq!(fixed, content, "Fix should not modify nested code blocks");
571    }
572
573    #[test]
574    fn test_nested_code_blocks_complex() {
575        let rule = MD031BlanksAroundFences::default();
576
577        // Test documentation example with nested code blocks
578        let content = r#"# Documentation
579
580## Examples
581
582````markdown
583```python
584def hello():
585    print("Hello, world!")
586```
587
588```javascript
589console.log("Hello, world!");
590```
591````
592
593More text here."#;
594
595        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596        let warnings = rule.check(&ctx).unwrap();
597        assert_eq!(
598            warnings.len(),
599            0,
600            "Should not flag any issues in properly formatted nested code blocks"
601        );
602
603        // Test with 5-backtick outer block
604        let content_5 = r#"`````markdown
605````python
606```bash
607echo "nested"
608```
609````
610`````"#;
611
612        let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
613        let warnings_5 = rule.check(&ctx_5).unwrap();
614        assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
615    }
616
617    #[test]
618    fn test_fix_preserves_trailing_newline() {
619        let rule = MD031BlanksAroundFences::default();
620
621        // Test content with trailing newline
622        let content = "Some text\n```\ncode\n```\nMore text\n";
623        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624        let fixed = rule.fix(&ctx).unwrap();
625
626        // Should preserve the trailing newline
627        assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
628        assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
629    }
630
631    #[test]
632    fn test_fix_preserves_no_trailing_newline() {
633        let rule = MD031BlanksAroundFences::default();
634
635        // Test content without trailing newline
636        let content = "Some text\n```\ncode\n```\nMore text";
637        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
638        let fixed = rule.fix(&ctx).unwrap();
639
640        // Should not add trailing newline if original didn't have one
641        assert!(
642            !fixed.ends_with('\n'),
643            "Fix should not add trailing newline if original didn't have one"
644        );
645        assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
646    }
647
648    #[test]
649    fn test_list_items_config_true() {
650        // Test with list_items: true (default) - should require blank lines even in lists
651        let rule = MD031BlanksAroundFences::new(true);
652
653        let content = "1. First item\n   ```python\n   code_in_list()\n   ```\n2. Second item";
654        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
655        let warnings = rule.check(&ctx).unwrap();
656
657        // Should flag missing blank lines before and after code block in list
658        assert_eq!(warnings.len(), 2);
659        assert!(warnings[0].message.contains("before"));
660        assert!(warnings[1].message.contains("after"));
661    }
662
663    #[test]
664    fn test_list_items_config_false() {
665        // Test with list_items: false - should NOT require blank lines in lists
666        let rule = MD031BlanksAroundFences::new(false);
667
668        let content = "1. First item\n   ```python\n   code_in_list()\n   ```\n2. Second item";
669        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
670        let warnings = rule.check(&ctx).unwrap();
671
672        // Should not flag missing blank lines inside lists
673        assert_eq!(warnings.len(), 0);
674    }
675
676    #[test]
677    fn test_list_items_config_false_outside_list() {
678        // Test with list_items: false - should still require blank lines outside lists
679        let rule = MD031BlanksAroundFences::new(false);
680
681        let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
682        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683        let warnings = rule.check(&ctx).unwrap();
684
685        // Should still flag missing blank lines outside lists
686        assert_eq!(warnings.len(), 2);
687        assert!(warnings[0].message.contains("before"));
688        assert!(warnings[1].message.contains("after"));
689    }
690
691    #[test]
692    fn test_default_config_section() {
693        let rule = MD031BlanksAroundFences::default();
694        let config_section = rule.default_config_section();
695
696        assert!(config_section.is_some());
697        let (name, value) = config_section.unwrap();
698        assert_eq!(name, "MD031");
699
700        // Should contain the list_items option with default value true
701        if let toml::Value::Table(table) = value {
702            assert!(table.contains_key("list-items"));
703            assert_eq!(table["list-items"], toml::Value::Boolean(true));
704        } else {
705            panic!("Expected TOML table");
706        }
707    }
708
709    #[test]
710    fn test_fix_list_items_config_false() {
711        // Test that fix respects list_items: false configuration
712        let rule = MD031BlanksAroundFences::new(false);
713
714        let content = "1. First item\n   ```python\n   code()\n   ```\n2. Second item";
715        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
716        let fixed = rule.fix(&ctx).unwrap();
717
718        // Should not add blank lines when list_items is false
719        assert_eq!(fixed, content);
720    }
721
722    #[test]
723    fn test_fix_list_items_config_true() {
724        // Test that fix respects list_items: true configuration
725        let rule = MD031BlanksAroundFences::new(true);
726
727        let content = "1. First item\n   ```python\n   code()\n   ```\n2. Second item";
728        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729        let fixed = rule.fix(&ctx).unwrap();
730
731        // Should add blank lines when list_items is true
732        let expected = "1. First item\n\n   ```python\n   code()\n   ```\n\n2. Second item";
733        assert_eq!(fixed, expected);
734    }
735
736    #[test]
737    fn test_no_warning_after_frontmatter() {
738        // Code block immediately after frontmatter should not trigger MD031
739        // This matches markdownlint behavior
740        let rule = MD031BlanksAroundFences::default();
741
742        let content = "---\ntitle: Test\n---\n```\ncode\n```";
743        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744        let warnings = rule.check(&ctx).unwrap();
745
746        // Should not flag missing blank line before code block after frontmatter
747        assert!(
748            warnings.is_empty(),
749            "Expected no warnings for code block after frontmatter, got: {warnings:?}"
750        );
751    }
752
753    #[test]
754    fn test_fix_does_not_add_blank_after_frontmatter() {
755        // Fix should not add blank line between frontmatter and code block
756        let rule = MD031BlanksAroundFences::default();
757
758        let content = "---\ntitle: Test\n---\n```\ncode\n```";
759        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760        let fixed = rule.fix(&ctx).unwrap();
761
762        // Should not add blank line after frontmatter
763        assert_eq!(fixed, content);
764    }
765
766    #[test]
767    fn test_frontmatter_with_blank_line_before_code() {
768        // If there's already a blank line between frontmatter and code, that's fine
769        let rule = MD031BlanksAroundFences::default();
770
771        let content = "---\ntitle: Test\n---\n\n```\ncode\n```";
772        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773        let warnings = rule.check(&ctx).unwrap();
774
775        assert!(warnings.is_empty());
776    }
777
778    #[test]
779    fn test_no_warning_for_admonition_after_frontmatter() {
780        // Admonition immediately after frontmatter should not trigger MD031
781        let rule = MD031BlanksAroundFences::default();
782
783        let content = "---\ntitle: Test\n---\n!!! note\n    This is a note";
784        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
785        let warnings = rule.check(&ctx).unwrap();
786
787        assert!(
788            warnings.is_empty(),
789            "Expected no warnings for admonition after frontmatter, got: {warnings:?}"
790        );
791    }
792
793    #[test]
794    fn test_toml_frontmatter_before_code() {
795        // TOML frontmatter should also be handled
796        let rule = MD031BlanksAroundFences::default();
797
798        let content = "+++\ntitle = \"Test\"\n+++\n```\ncode\n```";
799        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800        let warnings = rule.check(&ctx).unwrap();
801
802        assert!(
803            warnings.is_empty(),
804            "Expected no warnings for code block after TOML frontmatter, got: {warnings:?}"
805        );
806    }
807
808    #[test]
809    fn test_fenced_code_in_list_with_4_space_indent_issue_276() {
810        // Issue #276: Fenced code blocks inside lists with 4+ space indentation
811        // were not being detected because of the old 0-3 space CommonMark limit.
812        // Now we use pulldown-cmark which correctly handles list-indented fences.
813        let rule = MD031BlanksAroundFences::new(true);
814
815        // 4-space indented fenced code block in list (was not detected before fix)
816        let content =
817            "1. First item\n2. Second item with code:\n    ```python\n    print(\"Hello\")\n    ```\n3. Third item";
818        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
819        let warnings = rule.check(&ctx).unwrap();
820
821        // Should detect missing blank lines around the code block
822        assert_eq!(
823            warnings.len(),
824            2,
825            "Should detect fenced code in list with 4-space indent, got: {warnings:?}"
826        );
827        assert!(warnings[0].message.contains("before"));
828        assert!(warnings[1].message.contains("after"));
829
830        // Test the fix adds blank lines
831        let fixed = rule.fix(&ctx).unwrap();
832        let expected =
833            "1. First item\n2. Second item with code:\n\n    ```python\n    print(\"Hello\")\n    ```\n\n3. Third item";
834        assert_eq!(
835            fixed, expected,
836            "Fix should add blank lines around list-indented fenced code"
837        );
838    }
839
840    #[test]
841    fn test_fenced_code_in_list_with_mixed_indentation() {
842        // Test both 3-space and 4-space indented fenced code blocks in same document
843        let rule = MD031BlanksAroundFences::new(true);
844
845        let content = r#"# Test
846
8473-space indent:
8481. First item
849   ```python
850   code
851   ```
8522. Second item
853
8544-space indent:
8551. First item
856    ```python
857    code
858    ```
8592. Second item"#;
860
861        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
862        let warnings = rule.check(&ctx).unwrap();
863
864        // Should detect all 4 missing blank lines (2 per code block)
865        assert_eq!(
866            warnings.len(),
867            4,
868            "Should detect all fenced code blocks regardless of indentation, got: {warnings:?}"
869        );
870    }
871
872    #[test]
873    fn test_fix_preserves_blockquote_prefix_before_fence() {
874        // Issue #268: Fix should insert blockquote-prefixed blank lines inside blockquotes
875        let rule = MD031BlanksAroundFences::default();
876
877        let content = "> Text before
878> ```
879> code
880> ```";
881        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
882        let fixed = rule.fix(&ctx).unwrap();
883
884        // The blank line inserted before the fence should have the blockquote prefix
885        let expected = "> Text before
886>
887> ```
888> code
889> ```";
890        assert_eq!(
891            fixed, expected,
892            "Fix should insert '>' blank line, not plain blank line"
893        );
894    }
895
896    #[test]
897    fn test_fix_preserves_blockquote_prefix_after_fence() {
898        // Issue #268: Fix should insert blockquote-prefixed blank lines inside blockquotes
899        let rule = MD031BlanksAroundFences::default();
900
901        let content = "> ```
902> code
903> ```
904> Text after";
905        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
906        let fixed = rule.fix(&ctx).unwrap();
907
908        // The blank line inserted after the fence should have the blockquote prefix
909        let expected = "> ```
910> code
911> ```
912>
913> Text after";
914        assert_eq!(
915            fixed, expected,
916            "Fix should insert '>' blank line after fence, not plain blank line"
917        );
918    }
919
920    #[test]
921    fn test_fix_preserves_nested_blockquote_prefix() {
922        // Nested blockquotes should preserve the full prefix (e.g., ">>")
923        let rule = MD031BlanksAroundFences::default();
924
925        let content = ">> Nested quote
926>> ```
927>> code
928>> ```
929>> More text";
930        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
931        let fixed = rule.fix(&ctx).unwrap();
932
933        // Should insert ">>" blank lines, not ">" or plain
934        let expected = ">> Nested quote
935>>
936>> ```
937>> code
938>> ```
939>>
940>> More text";
941        assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
942    }
943
944    #[test]
945    fn test_fix_preserves_triple_nested_blockquote_prefix() {
946        // Triple-nested blockquotes should preserve full prefix
947        let rule = MD031BlanksAroundFences::default();
948
949        let content = ">>> Triple nested
950>>> ```
951>>> code
952>>> ```
953>>> More text";
954        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955        let fixed = rule.fix(&ctx).unwrap();
956
957        let expected = ">>> Triple nested
958>>>
959>>> ```
960>>> code
961>>> ```
962>>>
963>>> More text";
964        assert_eq!(
965            fixed, expected,
966            "Fix should preserve triple-nested blockquote prefix '>>>'"
967        );
968    }
969
970    // ==================== Quarto Flavor Tests ====================
971
972    #[test]
973    fn test_quarto_code_block_after_div_open() {
974        // Code block immediately after Quarto div opening should not require blank line
975        let rule = MD031BlanksAroundFences::default();
976        let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
977        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
978        let warnings = rule.check(&ctx).unwrap();
979        assert!(
980            warnings.is_empty(),
981            "Should not require blank line after Quarto div opening: {warnings:?}"
982        );
983    }
984
985    #[test]
986    fn test_quarto_code_block_before_div_close() {
987        // Code block immediately before Quarto div closing should not require blank line
988        let rule = MD031BlanksAroundFences::default();
989        let content = "::: {.callout-note}\nSome text\n```python\ncode\n```\n:::";
990        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
991        let warnings = rule.check(&ctx).unwrap();
992        // Should only warn about the blank before the code block (after "Some text"), not after
993        assert!(
994            warnings.len() <= 1,
995            "Should not require blank line before Quarto div closing: {warnings:?}"
996        );
997    }
998
999    #[test]
1000    fn test_quarto_code_block_outside_div_still_requires_blanks() {
1001        // Code block outside Quarto div should still require blank lines
1002        let rule = MD031BlanksAroundFences::default();
1003        let content = "Some text\n```python\ncode\n```\nMore text";
1004        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1005        let warnings = rule.check(&ctx).unwrap();
1006        assert_eq!(
1007            warnings.len(),
1008            2,
1009            "Should still require blank lines around code blocks outside divs"
1010        );
1011    }
1012
1013    #[test]
1014    fn test_quarto_code_block_with_callout_note() {
1015        // Code block inside callout-note should work without blank lines at boundaries
1016        let rule = MD031BlanksAroundFences::default();
1017        let content = "::: {.callout-note}\n```r\n1 + 1\n```\n:::\n\nMore text";
1018        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1019        let warnings = rule.check(&ctx).unwrap();
1020        assert!(
1021            warnings.is_empty(),
1022            "Callout note with code block should have no warnings: {warnings:?}"
1023        );
1024    }
1025
1026    #[test]
1027    fn test_quarto_nested_divs_with_code() {
1028        // Nested divs with code blocks
1029        let rule = MD031BlanksAroundFences::default();
1030        let content = "::: {.outer}\n::: {.inner}\n```python\ncode\n```\n:::\n:::\n";
1031        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1032        let warnings = rule.check(&ctx).unwrap();
1033        assert!(
1034            warnings.is_empty(),
1035            "Nested divs with code blocks should have no warnings: {warnings:?}"
1036        );
1037    }
1038
1039    #[test]
1040    fn test_quarto_div_markers_in_standard_flavor() {
1041        // In standard flavor, ::: is not special, so normal rules apply
1042        let rule = MD031BlanksAroundFences::default();
1043        let content = "::: {.callout-note}\n```python\ncode\n```\n:::\n";
1044        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045        let warnings = rule.check(&ctx).unwrap();
1046        // In standard flavor, both before and after the code block need blank lines
1047        // (unless the ":::" lines are treated as text and thus need blanks)
1048        assert!(
1049            !warnings.is_empty(),
1050            "Standard flavor should require blanks around code blocks: {warnings:?}"
1051        );
1052    }
1053
1054    #[test]
1055    fn test_quarto_fix_does_not_add_blanks_at_div_boundaries() {
1056        // Fix should not add blank lines at div boundaries
1057        let rule = MD031BlanksAroundFences::default();
1058        let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1059        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1060        let fixed = rule.fix(&ctx).unwrap();
1061        // Should remain unchanged - no blanks needed
1062        assert_eq!(fixed, content, "Fix should not add blanks at Quarto div boundaries");
1063    }
1064
1065    #[test]
1066    fn test_quarto_code_block_with_content_before() {
1067        // Code block with content before it (inside div) needs blank
1068        let rule = MD031BlanksAroundFences::default();
1069        let content = "::: {.callout-note}\nHere is some code:\n```python\ncode\n```\n:::";
1070        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1071        let warnings = rule.check(&ctx).unwrap();
1072        // Should warn about missing blank before code block (after "Here is some code:")
1073        assert_eq!(
1074            warnings.len(),
1075            1,
1076            "Should require blank before code block inside div: {warnings:?}"
1077        );
1078        assert!(warnings[0].message.contains("before"));
1079    }
1080
1081    #[test]
1082    fn test_quarto_code_block_with_content_after() {
1083        // Code block with content after it (inside div) needs blank
1084        let rule = MD031BlanksAroundFences::default();
1085        let content = "::: {.callout-note}\n```python\ncode\n```\nMore content here.\n:::";
1086        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1087        let warnings = rule.check(&ctx).unwrap();
1088        // Should warn about missing blank after code block (before "More content here.")
1089        assert_eq!(
1090            warnings.len(),
1091            1,
1092            "Should require blank after code block inside div: {warnings:?}"
1093        );
1094        assert!(warnings[0].message.contains("after"));
1095    }
1096
1097    #[test]
1098    fn test_pandoc_code_block_after_div_open() {
1099        // Code block immediately after a Pandoc div opening should not require a blank line,
1100        // mirroring the Quarto behavior tested in test_quarto_code_block_after_div_open.
1101        let rule = MD031BlanksAroundFences::default();
1102        let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1103        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1104        let warnings = rule.check(&ctx).unwrap();
1105        assert!(
1106            warnings.is_empty(),
1107            "MD031 should not require blank line after Pandoc div opening: {warnings:?}"
1108        );
1109    }
1110}