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