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::document_structure::{DocumentStructure, DocumentStructureExtensions};
7use crate::utils::kramdown_utils::is_kramdown_block_attribute;
8use crate::utils::range_utils::{LineIndex, 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    /// Check if blank line should be required based on configuration
100    fn should_require_blank_line(&self, line_index: usize, lines: &[&str]) -> bool {
101        if self.config.list_items {
102            // Always require blank lines when list_items is true
103            true
104        } else {
105            // Don't require blank lines inside lists when list_items is false
106            !self.is_in_list(line_index, lines)
107        }
108    }
109}
110
111impl Rule for MD031BlanksAroundFences {
112    fn name(&self) -> &'static str {
113        "MD031"
114    }
115
116    fn description(&self) -> &'static str {
117        "Fenced code blocks should be surrounded by blank lines"
118    }
119
120    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
121        let content = ctx.content;
122        let line_index = LineIndex::new(content.to_string());
123
124        let mut warnings = Vec::new();
125        let lines: Vec<&str> = content.lines().collect();
126
127        let mut in_code_block = false;
128        let mut current_fence_marker: Option<String> = None;
129        let mut i = 0;
130
131        while i < lines.len() {
132            let line = lines[i];
133            let trimmed = line.trim_start();
134
135            // Determine fence marker if this is a fence line
136            let fence_marker = if trimmed.starts_with("```") {
137                let backtick_count = trimmed.chars().take_while(|&c| c == '`').count();
138                if backtick_count >= 3 {
139                    Some("`".repeat(backtick_count))
140                } else {
141                    None
142                }
143            } else if trimmed.starts_with("~~~") {
144                let tilde_count = trimmed.chars().take_while(|&c| c == '~').count();
145                if tilde_count >= 3 {
146                    Some("~".repeat(tilde_count))
147                } else {
148                    None
149                }
150            } else {
151                None
152            };
153
154            if let Some(fence_marker) = fence_marker {
155                if in_code_block {
156                    // We're inside a code block, check if this closes it
157                    if let Some(ref current_marker) = current_fence_marker {
158                        // A fence can only close a code block if:
159                        // 1. It has the same type of marker (backticks or tildes)
160                        // 2. It has at least as many markers as the opening fence
161                        // 3. It has no content after the fence marker
162                        let same_type = (current_marker.starts_with('`') && fence_marker.starts_with('`'))
163                            || (current_marker.starts_with('~') && fence_marker.starts_with('~'));
164
165                        if same_type
166                            && fence_marker.len() >= current_marker.len()
167                            && trimmed[fence_marker.len()..].trim().is_empty()
168                        {
169                            // This closes the current code block
170                            in_code_block = false;
171                            current_fence_marker = None;
172
173                            // Check for blank line after closing fence
174                            // Allow Kramdown block attributes if configured
175                            if i + 1 < lines.len()
176                                && !Self::is_empty_line(lines[i + 1])
177                                && !is_kramdown_block_attribute(lines[i + 1])
178                                && self.should_require_blank_line(i, &lines)
179                            {
180                                let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
181
182                                warnings.push(LintWarning {
183                                    rule_name: Some(self.name()),
184                                    line: start_line,
185                                    column: start_col,
186                                    end_line,
187                                    end_column: end_col,
188                                    message: "No blank line after fenced code block".to_string(),
189                                    severity: Severity::Warning,
190                                    fix: Some(Fix {
191                                        range: line_index.line_col_to_byte_range_with_length(
192                                            i + 1,
193                                            lines[i].len() + 1,
194                                            0,
195                                        ),
196                                        replacement: "\n".to_string(),
197                                    }),
198                                });
199                            }
200                        }
201                        // else: This is content inside a code block (shorter fence or different type), ignore
202                    }
203                } else {
204                    // We're outside a code block, this opens one
205                    in_code_block = true;
206                    current_fence_marker = Some(fence_marker);
207
208                    // Check for blank line before opening fence
209                    if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
210                        let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
211
212                        warnings.push(LintWarning {
213                            rule_name: Some(self.name()),
214                            line: start_line,
215                            column: start_col,
216                            end_line,
217                            end_column: end_col,
218                            message: "No blank line before fenced code block".to_string(),
219                            severity: Severity::Warning,
220                            fix: Some(Fix {
221                                range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
222                                replacement: "\n".to_string(),
223                            }),
224                        });
225                    }
226                }
227            }
228            // If we're inside a code block, ignore all content lines
229            i += 1;
230        }
231
232        Ok(warnings)
233    }
234
235    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
236        let content = ctx.content;
237        let _line_index = LineIndex::new(content.to_string());
238
239        // Check if original content ended with newline
240        let had_trailing_newline = content.ends_with('\n');
241
242        let lines: Vec<&str> = content.lines().collect();
243
244        let mut result = Vec::new();
245        let mut in_code_block = false;
246        let mut current_fence_marker: Option<String> = None;
247
248        let mut i = 0;
249
250        while i < lines.len() {
251            let line = lines[i];
252            let trimmed = line.trim_start();
253
254            // Determine fence marker if this is a fence line
255            let fence_marker = if trimmed.starts_with("```") {
256                let backtick_count = trimmed.chars().take_while(|&c| c == '`').count();
257                if backtick_count >= 3 {
258                    Some("`".repeat(backtick_count))
259                } else {
260                    None
261                }
262            } else if trimmed.starts_with("~~~") {
263                let tilde_count = trimmed.chars().take_while(|&c| c == '~').count();
264                if tilde_count >= 3 {
265                    Some("~".repeat(tilde_count))
266                } else {
267                    None
268                }
269            } else {
270                None
271            };
272
273            if let Some(fence_marker) = fence_marker {
274                if in_code_block {
275                    // We're inside a code block, check if this closes it
276                    if let Some(ref current_marker) = current_fence_marker {
277                        if trimmed.starts_with(current_marker) && trimmed[current_marker.len()..].trim().is_empty() {
278                            // This closes the current code block
279                            result.push(line.to_string());
280                            in_code_block = false;
281                            current_fence_marker = None;
282
283                            // Add blank line after closing fence if needed
284                            // Don't add if next line is a Kramdown block attribute
285                            if i + 1 < lines.len()
286                                && !Self::is_empty_line(lines[i + 1])
287                                && !is_kramdown_block_attribute(lines[i + 1])
288                                && self.should_require_blank_line(i, &lines)
289                            {
290                                result.push(String::new());
291                            }
292                        } else {
293                            // This is content inside a code block (different fence marker)
294                            result.push(line.to_string());
295                        }
296                    } else {
297                        // This shouldn't happen, but preserve as content
298                        result.push(line.to_string());
299                    }
300                } else {
301                    // We're outside a code block, this opens one
302                    in_code_block = true;
303                    current_fence_marker = Some(fence_marker);
304
305                    // Add blank line before fence if needed
306                    if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
307                        result.push(String::new());
308                    }
309
310                    // Add opening fence
311                    result.push(line.to_string());
312                }
313            } else if in_code_block {
314                // We're inside a code block, preserve content as-is
315                result.push(line.to_string());
316            } else {
317                // We're outside code blocks, normal processing
318                result.push(line.to_string());
319            }
320            i += 1;
321        }
322
323        let fixed = result.join("\n");
324
325        // Preserve original trailing newline if it existed
326        let final_result = if had_trailing_newline && !fixed.ends_with('\n') {
327            format!("{fixed}\n")
328        } else {
329            fixed
330        };
331
332        Ok(final_result)
333    }
334
335    /// Get the category of this rule for selective processing
336    fn category(&self) -> RuleCategory {
337        RuleCategory::CodeBlock
338    }
339
340    /// Check if this rule should be skipped
341    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
342        let content = ctx.content;
343        content.is_empty() || (!content.contains("```") && !content.contains("~~~"))
344    }
345
346    /// Optimized check using document structure
347    fn check_with_structure(
348        &self,
349        ctx: &crate::lint_context::LintContext,
350        structure: &DocumentStructure,
351    ) -> LintResult {
352        let content = ctx.content;
353        // Early return if no code blocks
354        if !self.has_relevant_elements(ctx, structure) {
355            return Ok(Vec::new());
356        }
357
358        let line_index = LineIndex::new(content.to_string());
359        let mut warnings = Vec::new();
360        let lines: Vec<&str> = content.lines().collect();
361
362        // Process each code fence start and end
363        for &start_line in &structure.fenced_code_block_starts {
364            let line_num = start_line;
365
366            // Check for blank line before fence
367            if line_num > 1
368                && !Self::is_empty_line(lines[line_num - 2])
369                && self.should_require_blank_line(line_num - 1, &lines)
370            {
371                // Calculate precise character range for the entire fence line that needs a blank line before it
372                let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, lines[line_num - 1]);
373
374                warnings.push(LintWarning {
375                    rule_name: Some(self.name()),
376                    line: start_line,
377                    column: start_col,
378                    end_line,
379                    end_column: end_col,
380                    message: "No blank line before fenced code block".to_string(),
381                    severity: Severity::Warning,
382                    fix: Some(Fix {
383                        range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
384                        replacement: "\n".to_string(),
385                    }),
386                });
387            }
388        }
389
390        for &end_line in &structure.fenced_code_block_ends {
391            let line_num = end_line;
392
393            // Check for blank line after fence
394            if line_num < lines.len()
395                && !Self::is_empty_line(lines[line_num])
396                && self.should_require_blank_line(line_num - 1, &lines)
397            {
398                // Calculate precise character range for the entire fence line that needs a blank line after it
399                let (start_line_fence, start_col_fence, end_line_fence, end_col_fence) =
400                    calculate_line_range(line_num, lines[line_num - 1]);
401
402                warnings.push(LintWarning {
403                    rule_name: Some(self.name()),
404                    line: start_line_fence,
405                    column: start_col_fence,
406                    end_line: end_line_fence,
407                    end_column: end_col_fence,
408                    message: "No blank line after fenced code block".to_string(),
409                    severity: Severity::Warning,
410                    fix: Some(Fix {
411                        range: line_index.line_col_to_byte_range_with_length(
412                            line_num,
413                            lines[line_num - 1].len() + 1,
414                            0,
415                        ),
416                        replacement: "\n".to_string(),
417                    }),
418                });
419            }
420        }
421
422        Ok(warnings)
423    }
424
425    fn as_any(&self) -> &dyn std::any::Any {
426        self
427    }
428
429    fn default_config_section(&self) -> Option<(String, toml::Value)> {
430        let default_config = MD031Config::default();
431        let json_value = serde_json::to_value(&default_config).ok()?;
432        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
433        if let toml::Value::Table(table) = toml_value {
434            if !table.is_empty() {
435                Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
436            } else {
437                None
438            }
439        } else {
440            None
441        }
442    }
443
444    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
445    where
446        Self: Sized,
447    {
448        let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
449        Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
450    }
451}
452
453impl DocumentStructureExtensions for MD031BlanksAroundFences {
454    fn has_relevant_elements(
455        &self,
456        _ctx: &crate::lint_context::LintContext,
457        doc_structure: &DocumentStructure,
458    ) -> bool {
459        !doc_structure.fenced_code_block_starts.is_empty() || !doc_structure.fenced_code_block_ends.is_empty()
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use crate::lint_context::LintContext;
467    use crate::utils::document_structure::document_structure_from_str;
468
469    #[test]
470    fn test_with_document_structure() {
471        let rule = MD031BlanksAroundFences::default();
472
473        // Test with properly formatted code blocks
474        let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
475        let structure = document_structure_from_str(content);
476        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
477        let warnings = rule.check_with_structure(&ctx, &structure).unwrap();
478        assert!(
479            warnings.is_empty(),
480            "Expected no warnings for properly formatted code blocks"
481        );
482
483        // Test with missing blank line before
484        let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
485        let structure = document_structure_from_str(content);
486        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
487        let warnings = rule.check_with_structure(&ctx, &structure).unwrap();
488        assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
489        assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
490        assert!(
491            warnings[0].message.contains("before"),
492            "Warning should be about blank line before"
493        );
494
495        // Test with missing blank line after
496        let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
497        let structure = document_structure_from_str(content);
498        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
499        let warnings = rule.check_with_structure(&ctx, &structure).unwrap();
500        assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
501        assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
502        assert!(
503            warnings[0].message.contains("after"),
504            "Warning should be about blank line after"
505        );
506
507        // Test with missing blank lines both before and after
508        let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
509        let structure = document_structure_from_str(content);
510        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
511        let warnings = rule.check_with_structure(&ctx, &structure).unwrap();
512        assert_eq!(
513            warnings.len(),
514            2,
515            "Expected 2 warnings for missing blank lines before and after"
516        );
517    }
518
519    #[test]
520    fn test_nested_code_blocks() {
521        let rule = MD031BlanksAroundFences::default();
522
523        // Test that nested code blocks are not flagged
524        let content = r#"````markdown
525```
526content
527```
528````"#;
529        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
530        let warnings = rule.check(&ctx).unwrap();
531        assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
532
533        // Test that fixes don't corrupt nested blocks
534        let fixed = rule.fix(&ctx).unwrap();
535        assert_eq!(fixed, content, "Fix should not modify nested code blocks");
536    }
537
538    #[test]
539    fn test_nested_code_blocks_complex() {
540        let rule = MD031BlanksAroundFences::default();
541
542        // Test documentation example with nested code blocks
543        let content = r#"# Documentation
544
545## Examples
546
547````markdown
548```python
549def hello():
550    print("Hello, world!")
551```
552
553```javascript
554console.log("Hello, world!");
555```
556````
557
558More text here."#;
559
560        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
561        let warnings = rule.check(&ctx).unwrap();
562        assert_eq!(
563            warnings.len(),
564            0,
565            "Should not flag any issues in properly formatted nested code blocks"
566        );
567
568        // Test with 5-backtick outer block
569        let content_5 = r#"`````markdown
570````python
571```bash
572echo "nested"
573```
574````
575`````"#;
576
577        let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard);
578        let warnings_5 = rule.check(&ctx_5).unwrap();
579        assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
580    }
581
582    #[test]
583    fn test_fix_preserves_trailing_newline() {
584        let rule = MD031BlanksAroundFences::default();
585
586        // Test content with trailing newline
587        let content = "Some text\n```\ncode\n```\nMore text\n";
588        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
589        let fixed = rule.fix(&ctx).unwrap();
590
591        // Should preserve the trailing newline
592        assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
593        assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
594    }
595
596    #[test]
597    fn test_fix_preserves_no_trailing_newline() {
598        let rule = MD031BlanksAroundFences::default();
599
600        // Test content without trailing newline
601        let content = "Some text\n```\ncode\n```\nMore text";
602        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
603        let fixed = rule.fix(&ctx).unwrap();
604
605        // Should not add trailing newline if original didn't have one
606        assert!(
607            !fixed.ends_with('\n'),
608            "Fix should not add trailing newline if original didn't have one"
609        );
610        assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
611    }
612
613    #[test]
614    fn test_list_items_config_true() {
615        // Test with list_items: true (default) - should require blank lines even in lists
616        let rule = MD031BlanksAroundFences::new(true);
617
618        let content = "1. First item\n   ```python\n   code_in_list()\n   ```\n2. Second item";
619        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
620        let warnings = rule.check(&ctx).unwrap();
621
622        // Should flag missing blank lines before and after code block in list
623        assert_eq!(warnings.len(), 2);
624        assert!(warnings[0].message.contains("before"));
625        assert!(warnings[1].message.contains("after"));
626    }
627
628    #[test]
629    fn test_list_items_config_false() {
630        // Test with list_items: false - should NOT require blank lines in lists
631        let rule = MD031BlanksAroundFences::new(false);
632
633        let content = "1. First item\n   ```python\n   code_in_list()\n   ```\n2. Second item";
634        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
635        let warnings = rule.check(&ctx).unwrap();
636
637        // Should not flag missing blank lines inside lists
638        assert_eq!(warnings.len(), 0);
639    }
640
641    #[test]
642    fn test_list_items_config_false_outside_list() {
643        // Test with list_items: false - should still require blank lines outside lists
644        let rule = MD031BlanksAroundFences::new(false);
645
646        let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
647        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
648        let warnings = rule.check(&ctx).unwrap();
649
650        // Should still flag missing blank lines outside lists
651        assert_eq!(warnings.len(), 2);
652        assert!(warnings[0].message.contains("before"));
653        assert!(warnings[1].message.contains("after"));
654    }
655
656    #[test]
657    fn test_default_config_section() {
658        let rule = MD031BlanksAroundFences::default();
659        let config_section = rule.default_config_section();
660
661        assert!(config_section.is_some());
662        let (name, value) = config_section.unwrap();
663        assert_eq!(name, "MD031");
664
665        // Should contain the list_items option with default value true
666        if let toml::Value::Table(table) = value {
667            assert!(table.contains_key("list-items"));
668            assert_eq!(table["list-items"], toml::Value::Boolean(true));
669        } else {
670            panic!("Expected TOML table");
671        }
672    }
673
674    #[test]
675    fn test_fix_list_items_config_false() {
676        // Test that fix respects list_items: false configuration
677        let rule = MD031BlanksAroundFences::new(false);
678
679        let content = "1. First item\n   ```python\n   code()\n   ```\n2. Second item";
680        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
681        let fixed = rule.fix(&ctx).unwrap();
682
683        // Should not add blank lines when list_items is false
684        assert_eq!(fixed, content);
685    }
686
687    #[test]
688    fn test_fix_list_items_config_true() {
689        // Test that fix respects list_items: true configuration
690        let rule = MD031BlanksAroundFences::new(true);
691
692        let content = "1. First item\n   ```python\n   code()\n   ```\n2. Second item";
693        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
694        let fixed = rule.fix(&ctx).unwrap();
695
696        // Should add blank lines when list_items is true
697        let expected = "1. First item\n\n   ```python\n   code()\n   ```\n\n2. Second item";
698        assert_eq!(fixed, expected);
699    }
700}