Skip to main content

rumdl_lib/rules/
md031_blanks_around_fences.rs

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