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