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