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::kramdown_utils::is_kramdown_block_attribute;
7use crate::utils::mkdocs_admonitions;
8use crate::utils::range_utils::calculate_line_range;
9use serde::{Deserialize, Serialize};
10
11/// Configuration for MD031 rule
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13#[serde(rename_all = "kebab-case")]
14pub struct MD031Config {
15    /// Whether to require blank lines around code blocks in lists
16    #[serde(default = "default_list_items")]
17    pub list_items: bool,
18}
19
20impl Default for MD031Config {
21    fn default() -> Self {
22        Self {
23            list_items: default_list_items(),
24        }
25    }
26}
27
28fn default_list_items() -> bool {
29    true
30}
31
32impl RuleConfig for MD031Config {
33    const RULE_NAME: &'static str = "MD031";
34}
35
36/// Rule MD031: Fenced code blocks should be surrounded by blank lines
37#[derive(Clone, Default)]
38pub struct MD031BlanksAroundFences {
39    config: MD031Config,
40}
41
42impl MD031BlanksAroundFences {
43    pub fn new(list_items: bool) -> Self {
44        Self {
45            config: MD031Config { list_items },
46        }
47    }
48
49    pub fn from_config_struct(config: MD031Config) -> Self {
50        Self { config }
51    }
52
53    fn is_empty_line(line: &str) -> bool {
54        line.trim().is_empty()
55    }
56
57    /// Check if a line is inside a list item
58    fn is_in_list(&self, line_index: usize, lines: &[&str]) -> bool {
59        // Look backwards to find if we're in a list item
60        for i in (0..=line_index).rev() {
61            let line = lines[i];
62            let trimmed = line.trim_start();
63
64            // If we hit a blank line, we're no longer in a list
65            if trimmed.is_empty() {
66                return false;
67            }
68
69            // Check for ordered list (number followed by . or ))
70            if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
71                let mut chars = trimmed.chars().skip_while(|c| c.is_ascii_digit());
72                if let Some(next) = chars.next()
73                    && (next == '.' || next == ')')
74                    && chars.next() == Some(' ')
75                {
76                    return true;
77                }
78            }
79
80            // Check for unordered list
81            if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
82                return true;
83            }
84
85            // If this line is indented, it might be a continuation of a list item
86            let is_indented = line.starts_with("    ") || line.starts_with("\t") || line.starts_with("   ");
87            if is_indented {
88                continue; // Keep looking backwards for the list marker
89            }
90
91            // If we reach here and haven't found a list marker, and we're not at an indented line,
92            // then we're not in a list
93            return false;
94        }
95
96        false
97    }
98
99    /// Calculate indentation (number of leading spaces)
100    fn get_indentation(line: &str) -> usize {
101        line.chars().take_while(|c| *c == ' ').count()
102    }
103
104    /// Check if a line has a valid fence marker (CommonMark: 0-3 spaces max indentation)
105    fn get_fence_marker(line: &str) -> Option<String> {
106        let indent = Self::get_indentation(line);
107        // CommonMark: fences must have at most 3 spaces of indentation
108        if indent > 3 {
109            return None;
110        }
111
112        let trimmed = line.trim_start();
113        if trimmed.starts_with("```") {
114            let backtick_count = trimmed.chars().take_while(|&c| c == '`').count();
115            if backtick_count >= 3 {
116                return Some("`".repeat(backtick_count));
117            }
118        } else if trimmed.starts_with("~~~") {
119            let tilde_count = trimmed.chars().take_while(|&c| c == '~').count();
120            if tilde_count >= 3 {
121                return Some("~".repeat(tilde_count));
122            }
123        }
124        None
125    }
126
127    /// Check if blank line should be required based on configuration
128    fn should_require_blank_line(&self, line_index: usize, lines: &[&str]) -> bool {
129        if self.config.list_items {
130            // Always require blank lines when list_items is true
131            true
132        } else {
133            // Don't require blank lines inside lists when list_items is false
134            !self.is_in_list(line_index, lines)
135        }
136    }
137}
138
139impl Rule for MD031BlanksAroundFences {
140    fn name(&self) -> &'static str {
141        "MD031"
142    }
143
144    fn description(&self) -> &'static str {
145        "Fenced code blocks should be surrounded by blank lines"
146    }
147
148    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
149        let content = ctx.content;
150        let line_index = &ctx.line_index;
151
152        let mut warnings = Vec::new();
153        let lines: Vec<&str> = content.lines().collect();
154
155        let mut in_code_block = false;
156        let mut current_fence_marker: Option<String> = None;
157        let mut in_admonition = false;
158        let mut admonition_indent = 0;
159        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
160        let mut i = 0;
161
162        while i < lines.len() {
163            let line = lines[i];
164            let trimmed = line.trim_start();
165
166            // Check for MkDocs admonition start
167            if is_mkdocs && mkdocs_admonitions::is_admonition_start(line) {
168                // Check for blank line before admonition (similar to code blocks)
169                if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
170                    let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
171
172                    warnings.push(LintWarning {
173                        rule_name: Some(self.name().to_string()),
174                        line: start_line,
175                        column: start_col,
176                        end_line,
177                        end_column: end_col,
178                        message: "No blank line before admonition block".to_string(),
179                        severity: Severity::Warning,
180                        fix: Some(Fix {
181                            range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
182                            replacement: "\n".to_string(),
183                        }),
184                    });
185                }
186
187                in_admonition = true;
188                admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
189                i += 1;
190                continue;
191            }
192
193            // Check if we're exiting an admonition
194            if in_admonition {
195                if !line.trim().is_empty() && !mkdocs_admonitions::is_admonition_content(line, admonition_indent) {
196                    // We've exited the admonition
197                    in_admonition = false;
198
199                    // Check for blank line after admonition (current line should be blank)
200                    if !Self::is_empty_line(line) && self.should_require_blank_line(i - 1, &lines) {
201                        let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
202
203                        warnings.push(LintWarning {
204                            rule_name: Some(self.name().to_string()),
205                            line: start_line,
206                            column: start_col,
207                            end_line,
208                            end_column: end_col,
209                            message: "No blank line after admonition block".to_string(),
210                            severity: Severity::Warning,
211                            fix: Some(Fix {
212                                range: line_index.line_col_to_byte_range_with_length(i, 0, 0),
213                                replacement: "\n".to_string(),
214                            }),
215                        });
216                    }
217
218                    admonition_indent = 0;
219                    // Don't continue - process this line normally
220                } else {
221                    // Still in admonition
222                    i += 1;
223                    continue;
224                }
225            }
226
227            // Determine fence marker if this is a fence line (respects CommonMark 0-3 space limit)
228            let fence_marker = Self::get_fence_marker(line);
229
230            if let Some(fence_marker) = fence_marker {
231                if in_code_block {
232                    // We're inside a code block, check if this closes it
233                    if let Some(ref current_marker) = current_fence_marker {
234                        // A fence can only close a code block if:
235                        // 1. It has the same type of marker (backticks or tildes)
236                        // 2. It has at least as many markers as the opening fence
237                        // 3. It has no content after the fence marker
238                        let same_type = (current_marker.starts_with('`') && fence_marker.starts_with('`'))
239                            || (current_marker.starts_with('~') && fence_marker.starts_with('~'));
240
241                        if same_type
242                            && fence_marker.len() >= current_marker.len()
243                            && trimmed[fence_marker.len()..].trim().is_empty()
244                        {
245                            // This closes the current code block
246                            in_code_block = false;
247                            current_fence_marker = None;
248
249                            // Check for blank line after closing fence
250                            // Allow Kramdown block attributes if configured
251                            if i + 1 < lines.len()
252                                && !Self::is_empty_line(lines[i + 1])
253                                && !is_kramdown_block_attribute(lines[i + 1])
254                                && self.should_require_blank_line(i, &lines)
255                            {
256                                let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
257
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 after fenced code block".to_string(),
265                                    severity: Severity::Warning,
266                                    fix: Some(Fix {
267                                        range: line_index.line_col_to_byte_range_with_length(
268                                            i + 1,
269                                            lines[i].len() + 1,
270                                            0,
271                                        ),
272                                        replacement: "\n".to_string(),
273                                    }),
274                                });
275                            }
276                        }
277                        // else: This is content inside a code block (shorter fence or different type), ignore
278                    }
279                } else {
280                    // We're outside a code block, this opens one
281                    in_code_block = true;
282                    current_fence_marker = Some(fence_marker);
283
284                    // Check for blank line before opening fence
285                    if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
286                        let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
287
288                        warnings.push(LintWarning {
289                            rule_name: Some(self.name().to_string()),
290                            line: start_line,
291                            column: start_col,
292                            end_line,
293                            end_column: end_col,
294                            message: "No blank line before fenced code block".to_string(),
295                            severity: Severity::Warning,
296                            fix: Some(Fix {
297                                range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
298                                replacement: "\n".to_string(),
299                            }),
300                        });
301                    }
302                }
303            }
304            // If we're inside a code block, ignore all content lines
305            i += 1;
306        }
307
308        Ok(warnings)
309    }
310
311    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
312        let content = ctx.content;
313        let _line_index = &ctx.line_index;
314
315        // Check if original content ended with newline
316        let had_trailing_newline = content.ends_with('\n');
317
318        let lines: Vec<&str> = content.lines().collect();
319
320        let mut result = Vec::new();
321        let mut in_code_block = false;
322        let mut current_fence_marker: Option<String> = None;
323
324        let mut i = 0;
325
326        while i < lines.len() {
327            let line = lines[i];
328            let trimmed = line.trim_start();
329
330            // Determine fence marker if this is a fence line (respects CommonMark 0-3 space limit)
331            let fence_marker = Self::get_fence_marker(line);
332
333            if let Some(fence_marker) = fence_marker {
334                if in_code_block {
335                    // We're inside a code block, check if this closes it
336                    if let Some(ref current_marker) = current_fence_marker {
337                        if trimmed.starts_with(current_marker) && trimmed[current_marker.len()..].trim().is_empty() {
338                            // This closes the current code block
339                            result.push(line.to_string());
340                            in_code_block = false;
341                            current_fence_marker = None;
342
343                            // Add blank line after closing fence if needed
344                            // Don't add if next line is a Kramdown block attribute
345                            if i + 1 < lines.len()
346                                && !Self::is_empty_line(lines[i + 1])
347                                && !is_kramdown_block_attribute(lines[i + 1])
348                                && self.should_require_blank_line(i, &lines)
349                            {
350                                result.push(String::new());
351                            }
352                        } else {
353                            // This is content inside a code block (different fence marker)
354                            result.push(line.to_string());
355                        }
356                    } else {
357                        // This shouldn't happen, but preserve as content
358                        result.push(line.to_string());
359                    }
360                } else {
361                    // We're outside a code block, this opens one
362                    in_code_block = true;
363                    current_fence_marker = Some(fence_marker);
364
365                    // Add blank line before fence if needed
366                    if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
367                        result.push(String::new());
368                    }
369
370                    // Add opening fence
371                    result.push(line.to_string());
372                }
373            } else if in_code_block {
374                // We're inside a code block, preserve content as-is
375                result.push(line.to_string());
376            } else {
377                // We're outside code blocks, normal processing
378                result.push(line.to_string());
379            }
380            i += 1;
381        }
382
383        let fixed = result.join("\n");
384
385        // Preserve original trailing newline if it existed
386        let final_result = if had_trailing_newline && !fixed.ends_with('\n') {
387            format!("{fixed}\n")
388        } else {
389            fixed
390        };
391
392        Ok(final_result)
393    }
394
395    /// Get the category of this rule for selective processing
396    fn category(&self) -> RuleCategory {
397        RuleCategory::CodeBlock
398    }
399
400    /// Check if this rule should be skipped
401    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
402        // Check for fenced code blocks (backticks or tildes)
403        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
404    }
405
406    fn as_any(&self) -> &dyn std::any::Any {
407        self
408    }
409
410    fn default_config_section(&self) -> Option<(String, toml::Value)> {
411        let default_config = MD031Config::default();
412        let json_value = serde_json::to_value(&default_config).ok()?;
413        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
414        if let toml::Value::Table(table) = toml_value {
415            if !table.is_empty() {
416                Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
417            } else {
418                None
419            }
420        } else {
421            None
422        }
423    }
424
425    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
426    where
427        Self: Sized,
428    {
429        let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
430        Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::lint_context::LintContext;
438
439    #[test]
440    fn test_basic_functionality() {
441        let rule = MD031BlanksAroundFences::default();
442
443        // Test with properly formatted code blocks
444        let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
445        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446        let warnings = rule.check(&ctx).unwrap();
447        assert!(
448            warnings.is_empty(),
449            "Expected no warnings for properly formatted code blocks"
450        );
451
452        // Test with missing blank line before
453        let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
454        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
455        let warnings = rule.check(&ctx).unwrap();
456        assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
457        assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
458        assert!(
459            warnings[0].message.contains("before"),
460            "Warning should be about blank line before"
461        );
462
463        // Test with missing blank line after
464        let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
465        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
466        let warnings = rule.check(&ctx).unwrap();
467        assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
468        assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
469        assert!(
470            warnings[0].message.contains("after"),
471            "Warning should be about blank line after"
472        );
473
474        // Test with missing blank lines both before and after
475        let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
476        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
477        let warnings = rule.check(&ctx).unwrap();
478        assert_eq!(
479            warnings.len(),
480            2,
481            "Expected 2 warnings for missing blank lines before and after"
482        );
483    }
484
485    #[test]
486    fn test_nested_code_blocks() {
487        let rule = MD031BlanksAroundFences::default();
488
489        // Test that nested code blocks are not flagged
490        let content = r#"````markdown
491```
492content
493```
494````"#;
495        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496        let warnings = rule.check(&ctx).unwrap();
497        assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
498
499        // Test that fixes don't corrupt nested blocks
500        let fixed = rule.fix(&ctx).unwrap();
501        assert_eq!(fixed, content, "Fix should not modify nested code blocks");
502    }
503
504    #[test]
505    fn test_nested_code_blocks_complex() {
506        let rule = MD031BlanksAroundFences::default();
507
508        // Test documentation example with nested code blocks
509        let content = r#"# Documentation
510
511## Examples
512
513````markdown
514```python
515def hello():
516    print("Hello, world!")
517```
518
519```javascript
520console.log("Hello, world!");
521```
522````
523
524More text here."#;
525
526        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
527        let warnings = rule.check(&ctx).unwrap();
528        assert_eq!(
529            warnings.len(),
530            0,
531            "Should not flag any issues in properly formatted nested code blocks"
532        );
533
534        // Test with 5-backtick outer block
535        let content_5 = r#"`````markdown
536````python
537```bash
538echo "nested"
539```
540````
541`````"#;
542
543        let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
544        let warnings_5 = rule.check(&ctx_5).unwrap();
545        assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
546    }
547
548    #[test]
549    fn test_fix_preserves_trailing_newline() {
550        let rule = MD031BlanksAroundFences::default();
551
552        // Test content with trailing newline
553        let content = "Some text\n```\ncode\n```\nMore text\n";
554        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555        let fixed = rule.fix(&ctx).unwrap();
556
557        // Should preserve the trailing newline
558        assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
559        assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
560    }
561
562    #[test]
563    fn test_fix_preserves_no_trailing_newline() {
564        let rule = MD031BlanksAroundFences::default();
565
566        // Test content without trailing newline
567        let content = "Some text\n```\ncode\n```\nMore text";
568        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569        let fixed = rule.fix(&ctx).unwrap();
570
571        // Should not add trailing newline if original didn't have one
572        assert!(
573            !fixed.ends_with('\n'),
574            "Fix should not add trailing newline if original didn't have one"
575        );
576        assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
577    }
578
579    #[test]
580    fn test_list_items_config_true() {
581        // Test with list_items: true (default) - should require blank lines even in lists
582        let rule = MD031BlanksAroundFences::new(true);
583
584        let content = "1. First item\n   ```python\n   code_in_list()\n   ```\n2. Second item";
585        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586        let warnings = rule.check(&ctx).unwrap();
587
588        // Should flag missing blank lines before and after code block in list
589        assert_eq!(warnings.len(), 2);
590        assert!(warnings[0].message.contains("before"));
591        assert!(warnings[1].message.contains("after"));
592    }
593
594    #[test]
595    fn test_list_items_config_false() {
596        // Test with list_items: false - should NOT require blank lines in lists
597        let rule = MD031BlanksAroundFences::new(false);
598
599        let content = "1. First item\n   ```python\n   code_in_list()\n   ```\n2. Second item";
600        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601        let warnings = rule.check(&ctx).unwrap();
602
603        // Should not flag missing blank lines inside lists
604        assert_eq!(warnings.len(), 0);
605    }
606
607    #[test]
608    fn test_list_items_config_false_outside_list() {
609        // Test with list_items: false - should still require blank lines outside lists
610        let rule = MD031BlanksAroundFences::new(false);
611
612        let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
613        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614        let warnings = rule.check(&ctx).unwrap();
615
616        // Should still flag missing blank lines outside lists
617        assert_eq!(warnings.len(), 2);
618        assert!(warnings[0].message.contains("before"));
619        assert!(warnings[1].message.contains("after"));
620    }
621
622    #[test]
623    fn test_default_config_section() {
624        let rule = MD031BlanksAroundFences::default();
625        let config_section = rule.default_config_section();
626
627        assert!(config_section.is_some());
628        let (name, value) = config_section.unwrap();
629        assert_eq!(name, "MD031");
630
631        // Should contain the list_items option with default value true
632        if let toml::Value::Table(table) = value {
633            assert!(table.contains_key("list-items"));
634            assert_eq!(table["list-items"], toml::Value::Boolean(true));
635        } else {
636            panic!("Expected TOML table");
637        }
638    }
639
640    #[test]
641    fn test_fix_list_items_config_false() {
642        // Test that fix respects list_items: false configuration
643        let rule = MD031BlanksAroundFences::new(false);
644
645        let content = "1. First item\n   ```python\n   code()\n   ```\n2. Second item";
646        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
647        let fixed = rule.fix(&ctx).unwrap();
648
649        // Should not add blank lines when list_items is false
650        assert_eq!(fixed, content);
651    }
652
653    #[test]
654    fn test_fix_list_items_config_true() {
655        // Test that fix respects list_items: true configuration
656        let rule = MD031BlanksAroundFences::new(true);
657
658        let content = "1. First item\n   ```python\n   code()\n   ```\n2. Second item";
659        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
660        let fixed = rule.fix(&ctx).unwrap();
661
662        // Should add blank lines when list_items is true
663        let expected = "1. First item\n\n   ```python\n   code()\n   ```\n\n2. Second item";
664        assert_eq!(fixed, expected);
665    }
666}