rumdl_lib/rules/
md012_no_multiple_blanks.rs

1use crate::filtered_lines::FilteredLinesExt;
2use crate::utils::LineIndex;
3use crate::utils::range_utils::calculate_line_range;
4use std::collections::HashSet;
5use toml;
6
7use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
8use crate::rule_config_serde::RuleConfig;
9
10mod md012_config;
11use md012_config::MD012Config;
12
13/// Rule MD012: No multiple consecutive blank lines
14///
15/// See [docs/md012.md](../../docs/md012.md) for full documentation, configuration, and examples.
16
17#[derive(Debug, Clone, Default)]
18pub struct MD012NoMultipleBlanks {
19    config: MD012Config,
20}
21
22impl MD012NoMultipleBlanks {
23    pub fn new(maximum: usize) -> Self {
24        use crate::types::PositiveUsize;
25        Self {
26            config: MD012Config {
27                maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
28            },
29        }
30    }
31
32    pub const fn from_config_struct(config: MD012Config) -> Self {
33        Self { config }
34    }
35
36    /// Generate warnings for excess blank lines, handling common logic for all contexts
37    fn generate_excess_warnings(
38        &self,
39        blank_start: usize,
40        blank_count: usize,
41        lines: &[&str],
42        lines_to_check: &HashSet<usize>,
43        line_index: &LineIndex,
44    ) -> Vec<LintWarning> {
45        let mut warnings = Vec::new();
46
47        let location = if blank_start == 0 {
48            "at start of file"
49        } else {
50            "between content"
51        };
52
53        for i in self.config.maximum.get()..blank_count {
54            let excess_line_num = blank_start + i;
55            if lines_to_check.contains(&excess_line_num) {
56                let excess_line = excess_line_num + 1;
57                let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
58                let (start_line, start_col, end_line, end_col) = calculate_line_range(excess_line, excess_line_content);
59                warnings.push(LintWarning {
60                    rule_name: Some(self.name().to_string()),
61                    severity: Severity::Warning,
62                    message: format!("Multiple consecutive blank lines {location}"),
63                    line: start_line,
64                    column: start_col,
65                    end_line,
66                    end_column: end_col,
67                    fix: Some(Fix {
68                        range: {
69                            let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
70                            let line_end = line_index
71                                .get_line_start_byte(excess_line + 1)
72                                .unwrap_or(line_start + 1);
73                            line_start..line_end
74                        },
75                        replacement: String::new(),
76                    }),
77                });
78            }
79        }
80
81        warnings
82    }
83}
84
85impl Rule for MD012NoMultipleBlanks {
86    fn name(&self) -> &'static str {
87        "MD012"
88    }
89
90    fn description(&self) -> &'static str {
91        "Multiple consecutive blank lines"
92    }
93
94    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
95        let content = ctx.content;
96
97        // Early return for empty content
98        if content.is_empty() {
99            return Ok(Vec::new());
100        }
101
102        // Quick check for consecutive newlines or potential whitespace-only lines before processing
103        // Look for multiple consecutive lines that could be blank (empty or whitespace-only)
104        let lines: Vec<&str> = content.lines().collect();
105        let has_potential_blanks = lines
106            .windows(2)
107            .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
108
109        // Also check for blanks at EOF (markdownlint behavior)
110        // Content is normalized to LF at I/O boundary
111        let ends_with_multiple_newlines = content.ends_with("\n\n");
112
113        if !has_potential_blanks && !ends_with_multiple_newlines {
114            return Ok(Vec::new());
115        }
116
117        let line_index = &ctx.line_index;
118
119        let mut warnings = Vec::new();
120
121        // Single-pass algorithm with immediate counter reset
122        let mut blank_count = 0;
123        let mut blank_start = 0;
124        let mut last_line_num: Option<usize> = None;
125
126        // Use HashSet for O(1) lookups of lines that need to be checked
127        let mut lines_to_check: HashSet<usize> = HashSet::new();
128
129        // Use filtered_lines to automatically skip front-matter and code blocks
130        // The in_code_block field in LineInfo is pre-computed using pulldown-cmark
131        // and correctly handles both fenced code blocks and indented code blocks
132        for filtered_line in ctx.filtered_lines().skip_front_matter().skip_code_blocks() {
133            let line_num = filtered_line.line_num - 1; // Convert 1-based to 0-based for internal tracking
134            let line = filtered_line.content;
135
136            // Detect when lines were skipped (e.g., code block content)
137            // If we jump more than 1 line, there was content between, which breaks blank sequences
138            if let Some(last) = last_line_num
139                && line_num > last + 1
140            {
141                // Lines were skipped (code block or similar)
142                // Generate warnings for any accumulated blanks before the skip
143                if blank_count > self.config.maximum.get() {
144                    warnings.extend(self.generate_excess_warnings(
145                        blank_start,
146                        blank_count,
147                        &lines,
148                        &lines_to_check,
149                        line_index,
150                    ));
151                }
152                blank_count = 0;
153                lines_to_check.clear();
154            }
155            last_line_num = Some(line_num);
156
157            if line.trim().is_empty() {
158                if blank_count == 0 {
159                    blank_start = line_num;
160                }
161                blank_count += 1;
162                // Store line numbers that exceed the limit
163                if blank_count > self.config.maximum.get() {
164                    lines_to_check.insert(line_num);
165                }
166            } else {
167                if blank_count > self.config.maximum.get() {
168                    warnings.extend(self.generate_excess_warnings(
169                        blank_start,
170                        blank_count,
171                        &lines,
172                        &lines_to_check,
173                        line_index,
174                    ));
175                }
176                blank_count = 0;
177                lines_to_check.clear();
178            }
179        }
180
181        // Handle trailing blanks at EOF
182        // Main loop only reports mid-document blanks (between content)
183        // EOF handler reports trailing blanks with stricter rules (any blank at EOF is flagged)
184        //
185        // The blank_count at end of loop might include blanks BEFORE a code block at EOF,
186        // which aren't truly "trailing blanks". We need to verify the actual last line is blank.
187        let last_line_is_blank = lines.last().is_some_and(|l| l.trim().is_empty());
188
189        // Check for trailing blank lines
190        // EOF semantics: ANY blank line at EOF should be flagged (stricter than mid-document)
191        // Only fire if the actual last line(s) of the file are blank
192        if blank_count > 0 && last_line_is_blank {
193            let location = "at end of file";
194
195            // Report on the last line (which is blank)
196            let report_line = lines.len();
197
198            // Calculate fix: remove all trailing blank lines
199            // Find where the trailing blanks start (blank_count tells us how many consecutive blanks)
200            let fix_start = line_index
201                .get_line_start_byte(report_line - blank_count + 1)
202                .unwrap_or(0);
203            let fix_end = content.len();
204
205            // Report one warning for the excess blank lines at EOF
206            warnings.push(LintWarning {
207                rule_name: Some(self.name().to_string()),
208                severity: Severity::Warning,
209                message: format!("Multiple consecutive blank lines {location}"),
210                line: report_line,
211                column: 1,
212                end_line: report_line,
213                end_column: 1,
214                fix: Some(Fix {
215                    range: fix_start..fix_end,
216                    // Keep exactly one newline if content had newlines
217                    replacement: if content.ends_with('\n') {
218                        "\n".to_string()
219                    } else {
220                        String::new()
221                    },
222                }),
223            });
224        }
225
226        Ok(warnings)
227    }
228
229    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
230        let content = ctx.content;
231
232        let mut result = Vec::new();
233        let mut blank_count = 0;
234
235        let mut in_code_block = false;
236        let mut code_block_blanks = Vec::new();
237        let mut in_front_matter = false;
238
239        // Process ALL lines (don't skip front-matter in fix mode)
240        for filtered_line in ctx.filtered_lines() {
241            let line = filtered_line.content;
242
243            // Pass through front-matter lines unchanged
244            if filtered_line.line_info.in_front_matter {
245                if !in_front_matter {
246                    // Entering front-matter: flush any accumulated blanks
247                    let allowed_blanks = blank_count.min(self.config.maximum.get());
248                    if allowed_blanks > 0 {
249                        result.extend(vec![""; allowed_blanks]);
250                    }
251                    blank_count = 0;
252                    in_front_matter = true;
253                }
254                result.push(line);
255                continue;
256            } else if in_front_matter {
257                // Exiting front-matter
258                in_front_matter = false;
259            }
260
261            // Track code blocks
262            if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
263                // Handle accumulated blank lines before code block
264                if !in_code_block {
265                    let allowed_blanks = blank_count.min(self.config.maximum.get());
266                    if allowed_blanks > 0 {
267                        result.extend(vec![""; allowed_blanks]);
268                    }
269                    blank_count = 0;
270                } else {
271                    // Add accumulated blank lines inside code block
272                    result.append(&mut code_block_blanks);
273                }
274                in_code_block = !in_code_block;
275                result.push(line);
276                continue;
277            }
278
279            if in_code_block {
280                if line.trim().is_empty() {
281                    code_block_blanks.push(line);
282                } else {
283                    result.append(&mut code_block_blanks);
284                    result.push(line);
285                }
286            } else if line.trim().is_empty() {
287                blank_count += 1;
288            } else {
289                // Add allowed blank lines before content
290                let allowed_blanks = blank_count.min(self.config.maximum.get());
291                if allowed_blanks > 0 {
292                    result.extend(vec![""; allowed_blanks]);
293                }
294                blank_count = 0;
295                result.push(line);
296            }
297        }
298
299        // Trailing blank lines at EOF are removed entirely (matching markdownlint-cli)
300
301        // Join lines and handle final newline
302        let mut output = result.join("\n");
303        if content.ends_with('\n') {
304            output.push('\n');
305        }
306
307        Ok(output)
308    }
309
310    fn as_any(&self) -> &dyn std::any::Any {
311        self
312    }
313
314    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
315        // Skip if content is empty or doesn't have newlines (single line can't have multiple blanks)
316        ctx.content.is_empty() || !ctx.has_char('\n')
317    }
318
319    fn default_config_section(&self) -> Option<(String, toml::Value)> {
320        let default_config = MD012Config::default();
321        let json_value = serde_json::to_value(&default_config).ok()?;
322        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
323
324        if let toml::Value::Table(table) = toml_value {
325            if !table.is_empty() {
326                Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
327            } else {
328                None
329            }
330        } else {
331            None
332        }
333    }
334
335    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
336    where
337        Self: Sized,
338    {
339        let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
340        Box::new(Self::from_config_struct(rule_config))
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::lint_context::LintContext;
348
349    #[test]
350    fn test_single_blank_line_allowed() {
351        let rule = MD012NoMultipleBlanks::default();
352        let content = "Line 1\n\nLine 2\n\nLine 3";
353        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
354        let result = rule.check(&ctx).unwrap();
355        assert!(result.is_empty());
356    }
357
358    #[test]
359    fn test_multiple_blank_lines_flagged() {
360        let rule = MD012NoMultipleBlanks::default();
361        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
362        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
363        let result = rule.check(&ctx).unwrap();
364        assert_eq!(result.len(), 3); // 1 extra in first gap, 2 extra in second gap
365        assert_eq!(result[0].line, 3);
366        assert_eq!(result[1].line, 6);
367        assert_eq!(result[2].line, 7);
368    }
369
370    #[test]
371    fn test_custom_maximum() {
372        let rule = MD012NoMultipleBlanks::new(2);
373        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
374        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
375        let result = rule.check(&ctx).unwrap();
376        assert_eq!(result.len(), 1); // Only the fourth blank line is excessive
377        assert_eq!(result[0].line, 7);
378    }
379
380    #[test]
381    fn test_fix_multiple_blank_lines() {
382        let rule = MD012NoMultipleBlanks::default();
383        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
384        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
385        let fixed = rule.fix(&ctx).unwrap();
386        assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
387    }
388
389    #[test]
390    fn test_blank_lines_in_code_block() {
391        let rule = MD012NoMultipleBlanks::default();
392        let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
393        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
394        let result = rule.check(&ctx).unwrap();
395        assert!(result.is_empty()); // Blank lines inside code blocks are ignored
396    }
397
398    #[test]
399    fn test_fix_preserves_code_block_blanks() {
400        let rule = MD012NoMultipleBlanks::default();
401        let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
402        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
403        let fixed = rule.fix(&ctx).unwrap();
404        assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
405    }
406
407    #[test]
408    fn test_blank_lines_in_front_matter() {
409        let rule = MD012NoMultipleBlanks::default();
410        let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
411        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
412        let result = rule.check(&ctx).unwrap();
413        assert!(result.is_empty()); // Blank lines in front matter are ignored
414    }
415
416    #[test]
417    fn test_blank_lines_at_start() {
418        let rule = MD012NoMultipleBlanks::default();
419        let content = "\n\n\nContent";
420        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421        let result = rule.check(&ctx).unwrap();
422        assert_eq!(result.len(), 2);
423        assert!(result[0].message.contains("at start of file"));
424    }
425
426    #[test]
427    fn test_blank_lines_at_end() {
428        let rule = MD012NoMultipleBlanks::default();
429        let content = "Content\n\n\n";
430        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
431        let result = rule.check(&ctx).unwrap();
432        assert_eq!(result.len(), 1);
433        assert!(result[0].message.contains("at end of file"));
434    }
435
436    #[test]
437    fn test_single_blank_at_eof_flagged() {
438        // Markdownlint behavior: ANY blank lines at EOF are flagged
439        let rule = MD012NoMultipleBlanks::default();
440        let content = "Content\n\n";
441        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
442        let result = rule.check(&ctx).unwrap();
443        assert_eq!(result.len(), 1);
444        assert!(result[0].message.contains("at end of file"));
445    }
446
447    #[test]
448    fn test_whitespace_only_lines() {
449        let rule = MD012NoMultipleBlanks::default();
450        let content = "Line 1\n  \n\t\nLine 2";
451        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
452        let result = rule.check(&ctx).unwrap();
453        assert_eq!(result.len(), 1); // Whitespace-only lines count as blank
454    }
455
456    #[test]
457    fn test_indented_code_blocks() {
458        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
459        let rule = MD012NoMultipleBlanks::default();
460        let content = "Text\n\n    code\n    \n    \n    more code\n\nText";
461        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
462        let result = rule.check(&ctx).unwrap();
463        assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
464    }
465
466    #[test]
467    fn test_blanks_in_indented_code_block() {
468        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
469        let content = "    code line 1\n\n\n    code line 2\n";
470        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
471        let rule = MD012NoMultipleBlanks::default();
472        let warnings = rule.check(&ctx).unwrap();
473        assert!(warnings.is_empty(), "Should not flag blanks in indented code");
474    }
475
476    #[test]
477    fn test_blanks_in_indented_code_block_with_heading() {
478        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
479        let content = "# Heading\n\n    code line 1\n\n\n    code line 2\n\nMore text\n";
480        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481        let rule = MD012NoMultipleBlanks::default();
482        let warnings = rule.check(&ctx).unwrap();
483        assert!(
484            warnings.is_empty(),
485            "Should not flag blanks in indented code after heading"
486        );
487    }
488
489    #[test]
490    fn test_blanks_after_indented_code_block_flagged() {
491        // Blanks AFTER an indented code block end should still be flagged
492        let content = "# Heading\n\n    code line\n\n\n\nMore text\n";
493        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494        let rule = MD012NoMultipleBlanks::default();
495        let warnings = rule.check(&ctx).unwrap();
496        // There are 3 blank lines after the code block, so 2 extra should be flagged
497        assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
498    }
499
500    #[test]
501    fn test_fix_with_final_newline() {
502        let rule = MD012NoMultipleBlanks::default();
503        let content = "Line 1\n\n\nLine 2\n";
504        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
505        let fixed = rule.fix(&ctx).unwrap();
506        assert_eq!(fixed, "Line 1\n\nLine 2\n");
507        assert!(fixed.ends_with('\n'));
508    }
509
510    #[test]
511    fn test_empty_content() {
512        let rule = MD012NoMultipleBlanks::default();
513        let content = "";
514        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
515        let result = rule.check(&ctx).unwrap();
516        assert!(result.is_empty());
517    }
518
519    #[test]
520    fn test_nested_code_blocks() {
521        let rule = MD012NoMultipleBlanks::default();
522        let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
523        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524        let result = rule.check(&ctx).unwrap();
525        assert!(result.is_empty());
526    }
527
528    #[test]
529    fn test_unclosed_code_block() {
530        let rule = MD012NoMultipleBlanks::default();
531        let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
532        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533        let result = rule.check(&ctx).unwrap();
534        assert!(result.is_empty()); // Unclosed code blocks still preserve blank lines
535    }
536
537    #[test]
538    fn test_mixed_fence_styles() {
539        let rule = MD012NoMultipleBlanks::default();
540        let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
541        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542        let result = rule.check(&ctx).unwrap();
543        assert!(result.is_empty()); // Mixed fence styles should work
544    }
545
546    #[test]
547    fn test_config_from_toml() {
548        let mut config = crate::config::Config::default();
549        let mut rule_config = crate::config::RuleConfig::default();
550        rule_config
551            .values
552            .insert("maximum".to_string(), toml::Value::Integer(3));
553        config.rules.insert("MD012".to_string(), rule_config);
554
555        let rule = MD012NoMultipleBlanks::from_config(&config);
556        let content = "Line 1\n\n\n\nLine 2"; // 3 blank lines
557        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
558        let result = rule.check(&ctx).unwrap();
559        assert!(result.is_empty()); // 3 blank lines allowed with maximum=3
560    }
561
562    #[test]
563    fn test_blank_lines_between_sections() {
564        let rule = MD012NoMultipleBlanks::default();
565        let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
566        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
567        let result = rule.check(&ctx).unwrap();
568        assert_eq!(result.len(), 1);
569        assert_eq!(result[0].line, 5);
570    }
571
572    #[test]
573    fn test_fix_preserves_indented_code() {
574        let rule = MD012NoMultipleBlanks::default();
575        let content = "Text\n\n\n    code\n    \n    more code\n\n\nText";
576        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
577        let fixed = rule.fix(&ctx).unwrap();
578        // The fix removes the extra blank line, but this is expected behavior
579        assert_eq!(fixed, "Text\n\n    code\n\n    more code\n\nText");
580    }
581
582    #[test]
583    fn test_edge_case_only_blanks() {
584        let rule = MD012NoMultipleBlanks::default();
585        let content = "\n\n\n";
586        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587        let result = rule.check(&ctx).unwrap();
588        // With the new EOF handling, we report once at EOF
589        assert_eq!(result.len(), 1);
590        assert!(result[0].message.contains("at end of file"));
591    }
592
593    // Regression tests for blanks after code blocks (GitHub issue #199 related)
594
595    #[test]
596    fn test_blanks_after_fenced_code_block_mid_document() {
597        // This is the pattern from React repo test files that was being missed
598        let rule = MD012NoMultipleBlanks::default();
599        let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
600        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601        let result = rule.check(&ctx).unwrap();
602        // Should flag the double blank between code block and next heading
603        assert_eq!(result.len(), 1, "Should detect blanks after code block");
604        assert_eq!(result[0].line, 7, "Warning should be on line 7 (second blank)");
605        assert!(result[0].message.contains("between content"));
606    }
607
608    #[test]
609    fn test_blanks_after_code_block_at_eof() {
610        // Trailing blanks after code block at end of file
611        let rule = MD012NoMultipleBlanks::default();
612        let content = "# Heading\n\n```\ncode\n```\n\n\n";
613        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614        let result = rule.check(&ctx).unwrap();
615        // Should flag the trailing blanks at EOF
616        assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
617        assert!(result[0].message.contains("at end of file"));
618    }
619
620    #[test]
621    fn test_single_blank_after_code_block_allowed() {
622        // Single blank after code block is allowed (default max=1)
623        let rule = MD012NoMultipleBlanks::default();
624        let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
625        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
626        let result = rule.check(&ctx).unwrap();
627        assert!(result.is_empty(), "Single blank after code block should be allowed");
628    }
629
630    #[test]
631    fn test_multiple_code_blocks_with_blanks() {
632        // Multiple code blocks, each followed by blanks
633        let rule = MD012NoMultipleBlanks::default();
634        let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
635        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636        let result = rule.check(&ctx).unwrap();
637        // Should flag both double-blank sequences
638        assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
639    }
640
641    #[test]
642    fn test_whitespace_only_lines_after_code_block_at_eof() {
643        // Whitespace-only lines (not just empty) after code block at EOF
644        // This matches the React repo pattern where lines have trailing spaces
645        let rule = MD012NoMultipleBlanks::default();
646        let content = "```\ncode\n```\n   \n   \n";
647        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648        let result = rule.check(&ctx).unwrap();
649        assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
650        assert!(result[0].message.contains("at end of file"));
651    }
652}