Skip to main content

rumdl_lib/rules/
md031_blanks_around_fences.rs

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