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::blank_lines::is_blank_or_comment_only;
7use crate::utils::calculate_indentation_width_default;
8use crate::utils::mkdocs_admonitions;
9use crate::utils::mkdocs_attr_list::is_block_attribute_line;
10use crate::utils::pandoc;
11use crate::utils::range_utils::calculate_line_range;
12use serde::{Deserialize, Serialize};
13
14/// Configuration for MD031 rule
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16#[serde(rename_all = "kebab-case")]
17pub struct MD031Config {
18    /// Whether to require blank lines around code blocks in lists
19    #[serde(default = "default_list_items")]
20    pub list_items: bool,
21}
22
23impl Default for MD031Config {
24    fn default() -> Self {
25        Self {
26            list_items: default_list_items(),
27        }
28    }
29}
30
31fn default_list_items() -> bool {
32    true
33}
34
35impl RuleConfig for MD031Config {
36    const RULE_NAME: &'static str = "MD031";
37}
38
39/// Rule MD031: Fenced code blocks should be surrounded by blank lines
40#[derive(Clone, Default)]
41pub struct MD031BlanksAroundFences {
42    config: MD031Config,
43}
44
45impl MD031BlanksAroundFences {
46    pub fn new(list_items: bool) -> Self {
47        Self {
48            config: MD031Config { list_items },
49        }
50    }
51
52    pub fn from_config_struct(config: MD031Config) -> Self {
53        Self { config }
54    }
55
56    /// Check if a line is effectively empty (blank, a line holding nothing but
57    /// HTML comments, or an empty blockquote line like ">")
58    /// Uses the pre-computed blockquote info from LintContext for accurate detection
59    fn is_effectively_empty_line(line_idx: usize, lines: &[&str], ctx: &crate::lint_context::LintContext) -> bool {
60        let line = lines.get(line_idx).unwrap_or(&"");
61
62        // A blank line, or one contributing nothing but comments (#866)
63        if is_blank_or_comment_only(line) {
64            return true;
65        }
66
67        // Check if this is an empty blockquote line (like ">", "> ", ">>", etc.)
68        if let Some(line_info) = ctx.lines.get(line_idx)
69            && let Some(ref bq) = line_info.blockquote
70        {
71            // If the blockquote content is empty, this is effectively a blank line
72            return bq.content.trim().is_empty();
73        }
74
75        false
76    }
77
78    /// Check if a line is inside a list item
79    fn is_in_list(&self, line_index: usize, lines: &[&str]) -> bool {
80        // Look backwards to find if we're in a list item
81        for i in (0..=line_index).rev() {
82            let line = lines[i];
83            let trimmed = line.trim_start();
84
85            // If we hit a blank line, we're no longer in a list
86            if trimmed.is_empty() {
87                return false;
88            }
89
90            // Check for ordered list (number followed by . or ))
91            if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
92                let mut chars = trimmed.chars().skip_while(char::is_ascii_digit);
93                if let Some(next) = chars.next()
94                    && (next == '.' || next == ')')
95                    && chars.next() == Some(' ')
96                {
97                    return true;
98                }
99            }
100
101            // Check for unordered list
102            if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
103                return true;
104            }
105
106            // If this line is indented (3+ columns), it might be a continuation of a list item
107            let is_indented = calculate_indentation_width_default(line) >= 3;
108            if is_indented {
109                continue; // Keep looking backwards for the list marker
110            }
111
112            // If we reach here and haven't found a list marker, and we're not at an indented line,
113            // then we're not in a list
114            return false;
115        }
116
117        false
118    }
119
120    /// Check if blank line should be required based on configuration
121    fn should_require_blank_line(&self, line_index: usize, lines: &[&str]) -> bool {
122        if self.config.list_items {
123            // Always require blank lines when list_items is true
124            true
125        } else {
126            // Don't require blank lines inside lists when list_items is false
127            !self.is_in_list(line_index, lines)
128        }
129    }
130
131    /// Check if the current line is immediately after frontmatter (prev line is closing delimiter)
132    fn is_right_after_frontmatter(line_index: usize, ctx: &crate::lint_context::LintContext) -> bool {
133        line_index > 0
134            && ctx.lines.get(line_index - 1).is_some_and(|info| info.in_front_matter)
135            && ctx.lines.get(line_index).is_some_and(|info| !info.in_front_matter)
136    }
137
138    /// Derive fenced code block line ranges from pre-computed code_block_details.
139    ///
140    /// Returns a vector of (opening_line_idx, closing_line_idx) for each fenced code block.
141    /// The indices are 0-based line numbers.
142    fn fenced_block_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
143        let lines = ctx.raw_lines();
144
145        ctx.code_block_details
146            .iter()
147            .filter(|d| d.is_fenced)
148            .map(|detail| {
149                // Convert start byte offset to line index
150                let start_line = ctx
151                    .line_offsets
152                    .partition_point(|&off| off <= detail.start)
153                    .saturating_sub(1);
154
155                // Convert end byte offset to line index
156                let end_byte = if detail.end > 0 { detail.end - 1 } else { 0 };
157                let end_line = ctx
158                    .line_offsets
159                    .partition_point(|&off| off <= end_byte)
160                    .saturating_sub(1);
161
162                // Verify this is actually a closing fence line (not just end of content)
163                let end_line_content = lines.get(end_line).unwrap_or(&"");
164                let trimmed = end_line_content.trim();
165                let content_after_bq = if trimmed.starts_with('>') {
166                    trimmed.trim_start_matches(['>', ' ']).trim()
167                } else {
168                    trimmed
169                };
170                let is_closing_fence = (content_after_bq.starts_with("```") || content_after_bq.starts_with("~~~"))
171                    && content_after_bq
172                        .chars()
173                        .skip_while(|&c| c == '`' || c == '~')
174                        .all(char::is_whitespace);
175
176                if is_closing_fence {
177                    (start_line, end_line)
178                } else {
179                    (start_line, lines.len().saturating_sub(1))
180                }
181            })
182            .collect()
183    }
184
185    /// Convert colon fence byte ranges from LintContext into (opener_line, closer_line) pairs.
186    fn colon_fence_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
187        ctx.colon_fence_details()
188            .iter()
189            .map(|fence| {
190                let (start, end) = (fence.start, fence.end);
191                let start_line = ctx.line_offsets.partition_point(|&off| off <= start).saturating_sub(1);
192                let end_byte = if end > 0 { end - 1 } else { 0 };
193                let end_line = ctx
194                    .line_offsets
195                    .partition_point(|&off| off <= end_byte)
196                    .saturating_sub(1);
197                (start_line, end_line)
198            })
199            .collect()
200    }
201
202    /// Convert MyST directive byte ranges from LintContext into (opener_line, closer_line) pairs.
203    fn myst_directive_line_ranges(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
204        ctx.myst_directive_ranges()
205            .iter()
206            .map(|&(start, end)| {
207                let start_line = ctx.line_offsets.partition_point(|&off| off <= start).saturating_sub(1);
208                let end_byte = if end > 0 { end - 1 } else { 0 };
209                let end_line = ctx
210                    .line_offsets
211                    .partition_point(|&off| off <= end_byte)
212                    .saturating_sub(1);
213                (start_line, end_line)
214            })
215            .collect()
216    }
217}
218
219impl Rule for MD031BlanksAroundFences {
220    fn name(&self) -> &'static str {
221        "MD031"
222    }
223
224    fn description(&self) -> &'static str {
225        "Fenced code blocks should be surrounded by blank lines"
226    }
227
228    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
229        let mut warnings = Vec::new();
230        let lines = ctx.raw_lines();
231        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
232        let is_pandoc = ctx.flavor.is_pandoc_compatible();
233
234        // Detect fenced code blocks using pulldown-cmark (handles list-indented fences correctly)
235        let fenced_blocks = Self::fenced_block_line_ranges(ctx);
236
237        // Helper to check if a line is a Pandoc/Quarto div marker (opening or closing)
238        let is_pandoc_div_marker =
239            |line: &str| -> bool { is_pandoc && (pandoc::is_div_open(line) || pandoc::is_div_close(line)) };
240
241        // Check blank lines around each fenced code block
242        for (opening_line, closing_line) in &fenced_blocks {
243            // Skip fenced code blocks in skipped regions (comments, front-matter, etc.)
244            if ctx.line_info(*opening_line + 1).is_some_and(|info| {
245                info.in_html_comment
246                    || info.in_mdx_comment
247                    || info.in_front_matter
248                    || info.in_mkdocstrings
249                    || info.in_jsx_block
250                    || info.in_kramdown_extension_block
251                    || info.in_pymdown_block
252            }) {
253                continue;
254            }
255
256            // Check for blank line before opening fence
257            // Skip if right after frontmatter
258            // Skip if right after a Pandoc/Quarto div marker in Pandoc-compatible flavor
259            // Use is_effectively_empty_line to handle blockquote blank lines (issue #284)
260            let prev_line_is_pandoc_marker = *opening_line > 0 && is_pandoc_div_marker(lines[*opening_line - 1]);
261            if *opening_line > 0
262                && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
263                && !Self::is_right_after_frontmatter(*opening_line, ctx)
264                && !prev_line_is_pandoc_marker
265                && self.should_require_blank_line(*opening_line, lines)
266            {
267                let (start_line, start_col, end_line, end_col) =
268                    calculate_line_range(*opening_line + 1, lines[*opening_line]);
269
270                let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
271                warnings.push(LintWarning {
272                    rule_name: Some(self.name().to_string()),
273                    line: start_line,
274                    column: start_col,
275                    end_line,
276                    end_column: end_col,
277                    message: "No blank line before fenced code block".to_string(),
278                    severity: Severity::Warning,
279                    fix: Some(Fix::new(
280                        ctx.line_column_byte_range_with_length(*opening_line + 1, 1, 0),
281                        format!("{bq_prefix}\n"),
282                    )),
283                });
284            }
285
286            // Check for blank line after closing fence
287            // Allow block attribute lists attached to the fence (Kramdown IALs in any
288            // flavor, Hugo/MkDocs bare attr lists when the flavor enables them)
289            // Skip if followed by a Pandoc/Quarto div marker in Pandoc-compatible flavor
290            // Use is_effectively_empty_line to handle blockquote blank lines (issue #284)
291            let next_line_is_pandoc_marker =
292                *closing_line + 1 < lines.len() && is_pandoc_div_marker(lines[*closing_line + 1]);
293            if *closing_line + 1 < lines.len()
294                && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
295                && !is_block_attribute_line(lines[*closing_line + 1], ctx.flavor)
296                && !next_line_is_pandoc_marker
297                && self.should_require_blank_line(*closing_line, lines)
298            {
299                let (start_line, start_col, end_line, end_col) =
300                    calculate_line_range(*closing_line + 1, lines[*closing_line]);
301
302                let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
303                warnings.push(LintWarning {
304                    rule_name: Some(self.name().to_string()),
305                    line: start_line,
306                    column: start_col,
307                    end_line,
308                    end_column: end_col,
309                    message: "No blank line after fenced code block".to_string(),
310                    severity: Severity::Warning,
311                    fix: Some(Fix::new(
312                        ctx.line_column_byte_range_with_length(*closing_line + 2, 1, 0),
313                        format!("{bq_prefix}\n"),
314                    )),
315                });
316            }
317        }
318
319        // Enforce blank lines around Azure DevOps colon code fences.
320        if ctx.flavor.supports_colon_code_fences() {
321            let colon_blocks = Self::colon_fence_line_ranges(ctx);
322            for (opening_line, closing_line) in &colon_blocks {
323                // Check for blank line before opener
324                if *opening_line > 0
325                    && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
326                    && !Self::is_right_after_frontmatter(*opening_line, ctx)
327                    && self.should_require_blank_line(*opening_line, lines)
328                {
329                    let (start_line, start_col, end_line, end_col) =
330                        calculate_line_range(*opening_line + 1, lines[*opening_line]);
331                    let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_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 before colon code fence".to_string(),
339                        severity: Severity::Warning,
340                        fix: Some(Fix::new(
341                            ctx.line_column_byte_range_with_length(*opening_line + 1, 1, 0),
342                            format!("{bq_prefix}\n"),
343                        )),
344                    });
345                }
346
347                // Check for blank line after closer
348                if *closing_line + 1 < lines.len()
349                    && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
350                    && self.should_require_blank_line(*closing_line, lines)
351                {
352                    let (start_line, start_col, end_line, end_col) =
353                        calculate_line_range(*closing_line + 1, lines[*closing_line]);
354                    let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
355                    warnings.push(LintWarning {
356                        rule_name: Some(self.name().to_string()),
357                        line: start_line,
358                        column: start_col,
359                        end_line,
360                        end_column: end_col,
361                        message: "No blank line after colon code fence".to_string(),
362                        severity: Severity::Warning,
363                        fix: Some(Fix::new(
364                            ctx.line_column_byte_range_with_length(*closing_line + 2, 1, 0),
365                            format!("{bq_prefix}\n"),
366                        )),
367                    });
368                }
369            }
370        }
371
372        // Enforce blank lines around MyST colon directives (:::{name} ... :::)
373        if ctx.flavor.supports_myst_directives() {
374            let myst_blocks = Self::myst_directive_line_ranges(ctx);
375            for (opening_line, closing_line) in &myst_blocks {
376                // Check for blank line before opener
377                if *opening_line > 0
378                    && !Self::is_effectively_empty_line(*opening_line - 1, lines, ctx)
379                    && !Self::is_right_after_frontmatter(*opening_line, ctx)
380                    && self.should_require_blank_line(*opening_line, lines)
381                {
382                    let (start_line, start_col, end_line, end_col) =
383                        calculate_line_range(*opening_line + 1, lines[*opening_line]);
384                    let bq_prefix = ctx.blockquote_prefix_for_blank_line(*opening_line);
385                    warnings.push(LintWarning {
386                        rule_name: Some(self.name().to_string()),
387                        line: start_line,
388                        column: start_col,
389                        end_line,
390                        end_column: end_col,
391                        message: "No blank line before MyST directive".to_string(),
392                        severity: Severity::Warning,
393                        fix: Some(Fix::new(
394                            ctx.line_column_byte_range_with_length(*opening_line + 1, 1, 0),
395                            format!("{bq_prefix}\n"),
396                        )),
397                    });
398                }
399
400                // Check for blank line after closer
401                if *closing_line + 1 < lines.len()
402                    && !Self::is_effectively_empty_line(*closing_line + 1, lines, ctx)
403                    && self.should_require_blank_line(*closing_line, lines)
404                {
405                    let (start_line, start_col, end_line, end_col) =
406                        calculate_line_range(*closing_line + 1, lines[*closing_line]);
407                    let bq_prefix = ctx.blockquote_prefix_for_blank_line(*closing_line);
408                    warnings.push(LintWarning {
409                        rule_name: Some(self.name().to_string()),
410                        line: start_line,
411                        column: start_col,
412                        end_line,
413                        end_column: end_col,
414                        message: "No blank line after MyST directive".to_string(),
415                        severity: Severity::Warning,
416                        fix: Some(Fix::new(
417                            ctx.line_column_byte_range_with_length(*closing_line + 2, 1, 0),
418                            format!("{bq_prefix}\n"),
419                        )),
420                    });
421                }
422            }
423        }
424
425        // Handle MkDocs admonitions separately
426        if is_mkdocs {
427            let mut in_admonition = false;
428            let mut admonition_indent = 0;
429            let mut i = 0;
430
431            while i < lines.len() {
432                let line = lines[i];
433
434                // Skip if this line is inside a fenced code block
435                let in_fenced_block = fenced_blocks.iter().any(|(start, end)| i >= *start && i <= *end);
436                if in_fenced_block {
437                    i += 1;
438                    continue;
439                }
440
441                // Skip if this line is inside a PyMdown block
442                if ctx.line_info(i + 1).is_some_and(|info| info.in_pymdown_block) {
443                    i += 1;
444                    continue;
445                }
446
447                // Check for MkDocs admonition start
448                if mkdocs_admonitions::is_admonition_start(line) {
449                    // Check for blank line before admonition
450                    if i > 0
451                        && !Self::is_effectively_empty_line(i - 1, lines, ctx)
452                        && !Self::is_right_after_frontmatter(i, ctx)
453                        && self.should_require_blank_line(i, lines)
454                    {
455                        let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
456
457                        let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
458                        warnings.push(LintWarning {
459                            rule_name: Some(self.name().to_string()),
460                            line: start_line,
461                            column: start_col,
462                            end_line,
463                            end_column: end_col,
464                            message: "No blank line before admonition block".to_string(),
465                            severity: Severity::Warning,
466                            fix: Some(Fix::new(
467                                ctx.line_column_byte_range_with_length(i + 1, 1, 0),
468                                format!("{bq_prefix}\n"),
469                            )),
470                        });
471                    }
472
473                    in_admonition = true;
474                    admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
475                    i += 1;
476                    continue;
477                }
478
479                // Check if we're exiting an admonition
480                if in_admonition
481                    && !line.trim().is_empty()
482                    && !mkdocs_admonitions::is_admonition_content(line, admonition_indent)
483                {
484                    in_admonition = false;
485
486                    // Check for blank line after admonition
487                    // We need a blank line between the admonition content and the current line
488                    // Check if the previous line (i-1) is a blank line separator
489                    if i > 0
490                        && !Self::is_effectively_empty_line(i - 1, lines, ctx)
491                        && self.should_require_blank_line(i - 1, lines)
492                    {
493                        let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
494
495                        let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
496                        warnings.push(LintWarning {
497                            rule_name: Some(self.name().to_string()),
498                            line: start_line,
499                            column: start_col,
500                            end_line,
501                            end_column: end_col,
502                            message: "No blank line after admonition block".to_string(),
503                            severity: Severity::Warning,
504                            fix: Some(Fix::new(
505                                ctx.line_column_byte_range_with_length(i + 1, 1, 0),
506                                format!("{bq_prefix}\n"),
507                            )),
508                        });
509                    }
510
511                    admonition_indent = 0;
512                }
513
514                i += 1;
515            }
516        }
517
518        Ok(warnings)
519    }
520
521    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
522        if self.should_skip(ctx) {
523            return Ok(ctx.content.to_string());
524        }
525        let warnings = self.check(ctx)?;
526        if warnings.is_empty() {
527            return Ok(ctx.content.to_string());
528        }
529        let warnings =
530            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
531        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
532            .map_err(crate::rule::LintError::InvalidInput)
533    }
534
535    /// Get the category of this rule for selective processing
536    fn category(&self) -> RuleCategory {
537        RuleCategory::CodeBlock
538    }
539
540    /// Check if this rule should be skipped
541    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
542        if ctx.content.is_empty() {
543            return true;
544        }
545        let has_fences = ctx.likely_has_code() || ctx.has_char('~');
546        let has_mkdocs_admonitions = ctx.flavor == crate::config::MarkdownFlavor::MkDocs && ctx.content.contains("!!!");
547        let has_colon_fences = ctx.flavor.supports_colon_code_fences() && ctx.content.contains(":::");
548        let has_myst_directives = ctx.flavor.supports_myst_directives() && ctx.content.contains(":::");
549        !has_fences && !has_mkdocs_admonitions && !has_colon_fences && !has_myst_directives
550    }
551
552    fn as_any(&self) -> &dyn std::any::Any {
553        self
554    }
555
556    crate::impl_rule_config_methods!(MD031Config);
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use crate::lint_context::LintContext;
563
564    #[test]
565    fn test_basic_functionality() {
566        let rule = MD031BlanksAroundFences::default();
567
568        // Test with properly formatted code blocks
569        let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
570        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
571        let warnings = rule.check(&ctx).unwrap();
572        assert!(
573            warnings.is_empty(),
574            "Expected no warnings for properly formatted code blocks"
575        );
576
577        // Test with missing blank line before
578        let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
579        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
580        let warnings = rule.check(&ctx).unwrap();
581        assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
582        assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
583        assert!(
584            warnings[0].message.contains("before"),
585            "Warning should be about blank line before"
586        );
587
588        // Test with missing blank line after
589        let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
590        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591        let warnings = rule.check(&ctx).unwrap();
592        assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
593        assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
594        assert!(
595            warnings[0].message.contains("after"),
596            "Warning should be about blank line after"
597        );
598
599        // Test with missing blank lines both before and after
600        let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
601        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
602        let warnings = rule.check(&ctx).unwrap();
603        assert_eq!(
604            warnings.len(),
605            2,
606            "Expected 2 warnings for missing blank lines before and after"
607        );
608    }
609
610    #[test]
611    fn test_nested_code_blocks() {
612        let rule = MD031BlanksAroundFences::default();
613
614        // Test that nested code blocks are not flagged
615        let content = r#"````markdown
616```
617content
618```
619````"#;
620        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621        let warnings = rule.check(&ctx).unwrap();
622        assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
623
624        // Test that fixes don't corrupt nested blocks
625        let fixed = rule.fix(&ctx).unwrap();
626        assert_eq!(fixed, content, "Fix should not modify nested code blocks");
627    }
628
629    #[test]
630    fn test_nested_code_blocks_complex() {
631        let rule = MD031BlanksAroundFences::default();
632
633        // Test documentation example with nested code blocks
634        let content = r#"# Documentation
635
636## Examples
637
638````markdown
639```python
640def hello():
641    print("Hello, world!")
642```
643
644```javascript
645console.log("Hello, world!");
646```
647````
648
649More text here."#;
650
651        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652        let warnings = rule.check(&ctx).unwrap();
653        assert_eq!(
654            warnings.len(),
655            0,
656            "Should not flag any issues in properly formatted nested code blocks"
657        );
658
659        // Test with 5-backtick outer block
660        let content_5 = r#"`````markdown
661````python
662```bash
663echo "nested"
664```
665````
666`````"#;
667
668        let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
669        let warnings_5 = rule.check(&ctx_5).unwrap();
670        assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
671    }
672
673    #[test]
674    fn test_fix_preserves_trailing_newline() {
675        let rule = MD031BlanksAroundFences::default();
676
677        // Test content with trailing newline
678        let content = "Some text\n```\ncode\n```\nMore text\n";
679        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680        let fixed = rule.fix(&ctx).unwrap();
681
682        // Should preserve the trailing newline
683        assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
684        assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
685    }
686
687    #[test]
688    fn test_fix_preserves_no_trailing_newline() {
689        let rule = MD031BlanksAroundFences::default();
690
691        // Test content without trailing newline
692        let content = "Some text\n```\ncode\n```\nMore text";
693        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694        let fixed = rule.fix(&ctx).unwrap();
695
696        // Should not add trailing newline if original didn't have one
697        assert!(
698            !fixed.ends_with('\n'),
699            "Fix should not add trailing newline if original didn't have one"
700        );
701        assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
702    }
703
704    #[test]
705    fn test_list_items_config_true() {
706        // Test with list_items: true (default) - should require blank lines even in lists
707        let rule = MD031BlanksAroundFences::new(true);
708
709        let content = "1. First item\n   ```python\n   code_in_list()\n   ```\n2. Second item";
710        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711        let warnings = rule.check(&ctx).unwrap();
712
713        // Should flag missing blank lines before and after code block in list
714        assert_eq!(warnings.len(), 2);
715        assert!(warnings[0].message.contains("before"));
716        assert!(warnings[1].message.contains("after"));
717    }
718
719    #[test]
720    fn test_list_items_config_false() {
721        // Test with list_items: false - should NOT require blank lines in lists
722        let rule = MD031BlanksAroundFences::new(false);
723
724        let content = "1. First item\n   ```python\n   code_in_list()\n   ```\n2. Second item";
725        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726        let warnings = rule.check(&ctx).unwrap();
727
728        // Should not flag missing blank lines inside lists
729        assert_eq!(warnings.len(), 0);
730    }
731
732    #[test]
733    fn test_list_items_config_false_outside_list() {
734        // Test with list_items: false - should still require blank lines outside lists
735        let rule = MD031BlanksAroundFences::new(false);
736
737        let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
738        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739        let warnings = rule.check(&ctx).unwrap();
740
741        // Should still flag missing blank lines outside lists
742        assert_eq!(warnings.len(), 2);
743        assert!(warnings[0].message.contains("before"));
744        assert!(warnings[1].message.contains("after"));
745    }
746
747    #[test]
748    fn test_default_config_section() {
749        let rule = MD031BlanksAroundFences::default();
750        let config_section = rule.default_config_section();
751
752        assert!(config_section.is_some());
753        let (name, value) = config_section.unwrap();
754        assert_eq!(name, "MD031");
755
756        // Should contain the list_items option with default value true
757        if let toml::Value::Table(table) = value {
758            assert!(table.contains_key("list-items"));
759            assert_eq!(table["list-items"], toml::Value::Boolean(true));
760        } else {
761            panic!("Expected TOML table");
762        }
763    }
764
765    #[test]
766    fn test_fix_list_items_config_false() {
767        // Test that fix respects list_items: false configuration
768        let rule = MD031BlanksAroundFences::new(false);
769
770        let content = "1. First item\n   ```python\n   code()\n   ```\n2. Second item";
771        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
772        let fixed = rule.fix(&ctx).unwrap();
773
774        // Should not add blank lines when list_items is false
775        assert_eq!(fixed, content);
776    }
777
778    #[test]
779    fn test_fix_list_items_config_true() {
780        // Test that fix respects list_items: true configuration
781        let rule = MD031BlanksAroundFences::new(true);
782
783        let content = "1. First item\n   ```python\n   code()\n   ```\n2. Second item";
784        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785        let fixed = rule.fix(&ctx).unwrap();
786
787        // Should add blank lines when list_items is true
788        let expected = "1. First item\n\n   ```python\n   code()\n   ```\n\n2. Second item";
789        assert_eq!(fixed, expected);
790    }
791
792    #[test]
793    fn test_no_warning_after_frontmatter() {
794        // Code block immediately after frontmatter should not trigger MD031
795        // This matches markdownlint behavior
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        // Should not flag missing blank line before code block after frontmatter
803        assert!(
804            warnings.is_empty(),
805            "Expected no warnings for code block after frontmatter, got: {warnings:?}"
806        );
807    }
808
809    #[test]
810    fn test_fix_does_not_add_blank_after_frontmatter() {
811        // Fix should not add blank line between frontmatter and code block
812        let rule = MD031BlanksAroundFences::default();
813
814        let content = "---\ntitle: Test\n---\n```\ncode\n```";
815        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816        let fixed = rule.fix(&ctx).unwrap();
817
818        // Should not add blank line after frontmatter
819        assert_eq!(fixed, content);
820    }
821
822    #[test]
823    fn test_frontmatter_with_blank_line_before_code() {
824        // If there's already a blank line between frontmatter and code, that's fine
825        let rule = MD031BlanksAroundFences::default();
826
827        let content = "---\ntitle: Test\n---\n\n```\ncode\n```";
828        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
829        let warnings = rule.check(&ctx).unwrap();
830
831        assert!(warnings.is_empty());
832    }
833
834    #[test]
835    fn test_no_warning_for_admonition_after_frontmatter() {
836        // Admonition immediately after frontmatter should not trigger MD031
837        let rule = MD031BlanksAroundFences::default();
838
839        let content = "---\ntitle: Test\n---\n!!! note\n    This is a note";
840        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
841        let warnings = rule.check(&ctx).unwrap();
842
843        assert!(
844            warnings.is_empty(),
845            "Expected no warnings for admonition after frontmatter, got: {warnings:?}"
846        );
847    }
848
849    #[test]
850    fn test_toml_frontmatter_before_code() {
851        // TOML frontmatter should also be handled
852        let rule = MD031BlanksAroundFences::default();
853
854        let content = "+++\ntitle = \"Test\"\n+++\n```\ncode\n```";
855        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856        let warnings = rule.check(&ctx).unwrap();
857
858        assert!(
859            warnings.is_empty(),
860            "Expected no warnings for code block after TOML frontmatter, got: {warnings:?}"
861        );
862    }
863
864    #[test]
865    fn test_fenced_code_in_list_with_4_space_indent_issue_276() {
866        // Issue #276: Fenced code blocks inside lists with 4+ space indentation
867        // were not being detected because of the old 0-3 space CommonMark limit.
868        // Now we use pulldown-cmark which correctly handles list-indented fences.
869        let rule = MD031BlanksAroundFences::new(true);
870
871        // 4-space indented fenced code block in list (was not detected before fix)
872        let content =
873            "1. First item\n2. Second item with code:\n    ```python\n    print(\"Hello\")\n    ```\n3. Third item";
874        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875        let warnings = rule.check(&ctx).unwrap();
876
877        // Should detect missing blank lines around the code block
878        assert_eq!(
879            warnings.len(),
880            2,
881            "Should detect fenced code in list with 4-space indent, got: {warnings:?}"
882        );
883        assert!(warnings[0].message.contains("before"));
884        assert!(warnings[1].message.contains("after"));
885
886        // Test the fix adds blank lines
887        let fixed = rule.fix(&ctx).unwrap();
888        let expected =
889            "1. First item\n2. Second item with code:\n\n    ```python\n    print(\"Hello\")\n    ```\n\n3. Third item";
890        assert_eq!(
891            fixed, expected,
892            "Fix should add blank lines around list-indented fenced code"
893        );
894    }
895
896    #[test]
897    fn test_fenced_code_in_list_with_mixed_indentation() {
898        // Test both 3-space and 4-space indented fenced code blocks in same document
899        let rule = MD031BlanksAroundFences::new(true);
900
901        let content = r#"# Test
902
9033-space indent:
9041. First item
905   ```python
906   code
907   ```
9082. Second item
909
9104-space indent:
9111. First item
912    ```python
913    code
914    ```
9152. Second item"#;
916
917        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
918        let warnings = rule.check(&ctx).unwrap();
919
920        // Should detect all 4 missing blank lines (2 per code block)
921        assert_eq!(
922            warnings.len(),
923            4,
924            "Should detect all fenced code blocks regardless of indentation, got: {warnings:?}"
925        );
926    }
927
928    #[test]
929    fn test_fix_preserves_blockquote_prefix_before_fence() {
930        // Issue #268: Fix should insert blockquote-prefixed blank lines inside blockquotes
931        let rule = MD031BlanksAroundFences::default();
932
933        let content = "> Text before
934> ```
935> code
936> ```";
937        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938        let fixed = rule.fix(&ctx).unwrap();
939
940        // The blank line inserted before the fence should have the blockquote prefix
941        let expected = "> Text before
942>
943> ```
944> code
945> ```";
946        assert_eq!(
947            fixed, expected,
948            "Fix should insert '>' blank line, not plain blank line"
949        );
950    }
951
952    #[test]
953    fn test_fix_preserves_blockquote_prefix_after_fence() {
954        // Issue #268: Fix should insert blockquote-prefixed blank lines inside blockquotes
955        let rule = MD031BlanksAroundFences::default();
956
957        let content = "> ```
958> code
959> ```
960> Text after";
961        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
962        let fixed = rule.fix(&ctx).unwrap();
963
964        // The blank line inserted after the fence should have the blockquote prefix
965        let expected = "> ```
966> code
967> ```
968>
969> Text after";
970        assert_eq!(
971            fixed, expected,
972            "Fix should insert '>' blank line after fence, not plain blank line"
973        );
974    }
975
976    #[test]
977    fn test_fix_preserves_nested_blockquote_prefix() {
978        // Nested blockquotes should preserve the full prefix (e.g., ">>")
979        let rule = MD031BlanksAroundFences::default();
980
981        let content = ">> Nested quote
982>> ```
983>> code
984>> ```
985>> More text";
986        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
987        let fixed = rule.fix(&ctx).unwrap();
988
989        // Should insert ">>" blank lines, not ">" or plain
990        let expected = ">> Nested quote
991>>
992>> ```
993>> code
994>> ```
995>>
996>> More text";
997        assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
998    }
999
1000    #[test]
1001    fn test_fix_preserves_triple_nested_blockquote_prefix() {
1002        // Triple-nested blockquotes should preserve full prefix
1003        let rule = MD031BlanksAroundFences::default();
1004
1005        let content = ">>> Triple nested
1006>>> ```
1007>>> code
1008>>> ```
1009>>> More text";
1010        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011        let fixed = rule.fix(&ctx).unwrap();
1012
1013        let expected = ">>> Triple nested
1014>>>
1015>>> ```
1016>>> code
1017>>> ```
1018>>>
1019>>> More text";
1020        assert_eq!(
1021            fixed, expected,
1022            "Fix should preserve triple-nested blockquote prefix '>>>'"
1023        );
1024    }
1025
1026    // ==================== Quarto Flavor Tests ====================
1027
1028    #[test]
1029    fn test_quarto_code_block_after_div_open() {
1030        // Code block immediately after Quarto div opening should not require blank line
1031        let rule = MD031BlanksAroundFences::default();
1032        let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1033        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1034        let warnings = rule.check(&ctx).unwrap();
1035        assert!(
1036            warnings.is_empty(),
1037            "Should not require blank line after Quarto div opening: {warnings:?}"
1038        );
1039    }
1040
1041    #[test]
1042    fn test_quarto_code_block_before_div_close() {
1043        // Code block immediately before Quarto div closing should not require blank line
1044        let rule = MD031BlanksAroundFences::default();
1045        let content = "::: {.callout-note}\nSome text\n```python\ncode\n```\n:::";
1046        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1047        let warnings = rule.check(&ctx).unwrap();
1048        // Should only warn about the blank before the code block (after "Some text"), not after
1049        assert!(
1050            warnings.len() <= 1,
1051            "Should not require blank line before Quarto div closing: {warnings:?}"
1052        );
1053    }
1054
1055    #[test]
1056    fn test_quarto_code_block_outside_div_still_requires_blanks() {
1057        // Code block outside Quarto div should still require blank lines
1058        let rule = MD031BlanksAroundFences::default();
1059        let content = "Some text\n```python\ncode\n```\nMore text";
1060        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1061        let warnings = rule.check(&ctx).unwrap();
1062        assert_eq!(
1063            warnings.len(),
1064            2,
1065            "Should still require blank lines around code blocks outside divs"
1066        );
1067    }
1068
1069    #[test]
1070    fn test_quarto_code_block_with_callout_note() {
1071        // Code block inside callout-note should work without blank lines at boundaries
1072        let rule = MD031BlanksAroundFences::default();
1073        let content = "::: {.callout-note}\n```r\n1 + 1\n```\n:::\n\nMore text";
1074        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1075        let warnings = rule.check(&ctx).unwrap();
1076        assert!(
1077            warnings.is_empty(),
1078            "Callout note with code block should have no warnings: {warnings:?}"
1079        );
1080    }
1081
1082    #[test]
1083    fn test_quarto_nested_divs_with_code() {
1084        // Nested divs with code blocks
1085        let rule = MD031BlanksAroundFences::default();
1086        let content = "::: {.outer}\n::: {.inner}\n```python\ncode\n```\n:::\n:::\n";
1087        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1088        let warnings = rule.check(&ctx).unwrap();
1089        assert!(
1090            warnings.is_empty(),
1091            "Nested divs with code blocks should have no warnings: {warnings:?}"
1092        );
1093    }
1094
1095    #[test]
1096    fn test_quarto_div_markers_in_standard_flavor() {
1097        // In standard flavor, ::: is not special, so normal rules apply
1098        let rule = MD031BlanksAroundFences::default();
1099        let content = "::: {.callout-note}\n```python\ncode\n```\n:::\n";
1100        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1101        let warnings = rule.check(&ctx).unwrap();
1102        // In standard flavor, both before and after the code block need blank lines
1103        // (unless the ":::" lines are treated as text and thus need blanks)
1104        assert!(
1105            !warnings.is_empty(),
1106            "Standard flavor should require blanks around code blocks: {warnings:?}"
1107        );
1108    }
1109
1110    #[test]
1111    fn test_quarto_fix_does_not_add_blanks_at_div_boundaries() {
1112        // Fix should not add blank lines at div boundaries
1113        let rule = MD031BlanksAroundFences::default();
1114        let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1115        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1116        let fixed = rule.fix(&ctx).unwrap();
1117        // Should remain unchanged - no blanks needed
1118        assert_eq!(fixed, content, "Fix should not add blanks at Quarto div boundaries");
1119    }
1120
1121    #[test]
1122    fn test_quarto_code_block_with_content_before() {
1123        // Code block with content before it (inside div) needs blank
1124        let rule = MD031BlanksAroundFences::default();
1125        let content = "::: {.callout-note}\nHere is some code:\n```python\ncode\n```\n:::";
1126        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1127        let warnings = rule.check(&ctx).unwrap();
1128        // Should warn about missing blank before code block (after "Here is some code:")
1129        assert_eq!(
1130            warnings.len(),
1131            1,
1132            "Should require blank before code block inside div: {warnings:?}"
1133        );
1134        assert!(warnings[0].message.contains("before"));
1135    }
1136
1137    #[test]
1138    fn test_quarto_code_block_with_content_after() {
1139        // Code block with content after it (inside div) needs blank
1140        let rule = MD031BlanksAroundFences::default();
1141        let content = "::: {.callout-note}\n```python\ncode\n```\nMore content here.\n:::";
1142        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1143        let warnings = rule.check(&ctx).unwrap();
1144        // Should warn about missing blank after code block (before "More content here.")
1145        assert_eq!(
1146            warnings.len(),
1147            1,
1148            "Should require blank after code block inside div: {warnings:?}"
1149        );
1150        assert!(warnings[0].message.contains("after"));
1151    }
1152
1153    #[test]
1154    fn test_pandoc_code_block_after_div_open() {
1155        // Code block immediately after a Pandoc div opening should not require a blank line,
1156        // mirroring the Quarto behavior tested in test_quarto_code_block_after_div_open.
1157        let rule = MD031BlanksAroundFences::default();
1158        let content = "::: {.callout-note}\n```python\ncode\n```\n:::";
1159        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1160        let warnings = rule.check(&ctx).unwrap();
1161        assert!(
1162            warnings.is_empty(),
1163            "MD031 should not require blank line after Pandoc div opening: {warnings:?}"
1164        );
1165    }
1166
1167    #[test]
1168    fn test_md031_html_comment() {
1169        let rule = MD031BlanksAroundFences::default();
1170        let content = "text <!--\n```rust\nconst x = 1;\n```\n-->";
1171        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1172        let warnings = rule.check(&ctx).unwrap();
1173        assert!(
1174            warnings.is_empty(),
1175            "MD031 should not require blank lines around fenced blocks inside HTML comments: {warnings:?}"
1176        );
1177    }
1178
1179    #[test]
1180    fn test_md031_block_attribute_after_fence_not_flagged() {
1181        // Issue #756: a block attribute list directly under a fenced code block
1182        // describes that block, so MD031 must not require a blank between them.
1183        let rule = MD031BlanksAroundFences::default();
1184        let content = "Some text.\n\n```rust\nlet x = 1;\n```\n{class=\"highlight\"}\n\nMore text.";
1185
1186        for flavor in [
1187            crate::config::MarkdownFlavor::Hugo,
1188            crate::config::MarkdownFlavor::MkDocs,
1189            crate::config::MarkdownFlavor::Kramdown,
1190        ] {
1191            let ctx = LintContext::new(content, flavor, None);
1192            let warnings = rule.check(&ctx).unwrap();
1193            assert!(
1194                warnings.is_empty(),
1195                "MD031 should not flag the block attribute line under {flavor:?}: {warnings:?}"
1196            );
1197        }
1198
1199        // Negative control: in Standard the attr list is literal content, so the
1200        // closing fence genuinely has no blank line after it.
1201        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1202        let warnings_std = rule.check(&ctx_std).unwrap();
1203        assert!(
1204            warnings_std
1205                .iter()
1206                .any(|w| w.message == "No blank line after fenced code block"),
1207            "MD031 must flag the missing blank after the fence under Standard: {warnings_std:?}"
1208        );
1209    }
1210}