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