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