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