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