rumdl_lib/rules/
md012_no_multiple_blanks.rs

1use crate::utils::range_utils::{LineIndex, calculate_line_range};
2use std::collections::HashSet;
3use toml;
4
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
6use crate::rule_config_serde::RuleConfig;
7
8mod md012_config;
9use md012_config::MD012Config;
10
11/// Rule MD012: No multiple consecutive blank lines
12///
13/// See [docs/md012.md](../../docs/md012.md) for full documentation, configuration, and examples.
14
15#[derive(Debug, Clone, Default)]
16pub struct MD012NoMultipleBlanks {
17    config: MD012Config,
18}
19
20impl MD012NoMultipleBlanks {
21    pub fn new(maximum: usize) -> Self {
22        Self {
23            config: MD012Config { maximum },
24        }
25    }
26
27    pub fn from_config_struct(config: MD012Config) -> Self {
28        Self { config }
29    }
30}
31
32impl Rule for MD012NoMultipleBlanks {
33    fn name(&self) -> &'static str {
34        "MD012"
35    }
36
37    fn description(&self) -> &'static str {
38        "Multiple consecutive blank lines"
39    }
40
41    fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
42        Some(self)
43    }
44
45    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
46        let content = ctx.content;
47
48        // Early return for empty content
49        if content.is_empty() {
50            return Ok(Vec::new());
51        }
52
53        // Quick check for consecutive newlines or potential whitespace-only lines before processing
54        // Look for multiple consecutive lines that could be blank (empty or whitespace-only)
55        let lines: Vec<&str> = content.lines().collect();
56        let has_potential_blanks = lines
57            .windows(2)
58            .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
59
60        // Also check for blanks at EOF (markdownlint behavior)
61        let ends_with_multiple_newlines = content.ends_with("\n\n") || content.ends_with("\r\n\r\n");
62
63        if !has_potential_blanks && !ends_with_multiple_newlines {
64            return Ok(Vec::new());
65        }
66
67        let _line_index = LineIndex::new(content.to_string());
68
69        let mut warnings = Vec::new();
70
71        // Single-pass algorithm with immediate counter reset
72        let mut blank_count = 0;
73        let mut blank_start = 0;
74        let mut in_code_block = false;
75        let mut in_front_matter = false;
76        let mut code_fence_marker = "";
77
78        // Use HashSet for O(1) lookups of lines that need to be checked
79        let mut lines_to_check: HashSet<usize> = HashSet::new();
80
81        for (line_num, &line) in lines.iter().enumerate() {
82            let trimmed = line.trim_start();
83
84            // Check for front matter boundaries (only at start of file)
85            if trimmed == "---" {
86                if line_num == 0 {
87                    in_front_matter = true;
88                } else if in_front_matter {
89                    in_front_matter = false;
90                }
91                // Check for excess blanks before resetting
92                if blank_count > self.config.maximum {
93                    let location = if blank_start == 0 {
94                        "at start of file"
95                    } else {
96                        "between content"
97                    };
98                    for i in self.config.maximum..blank_count {
99                        let excess_line_num = blank_start + i;
100                        if lines_to_check.contains(&excess_line_num) {
101                            let excess_line = excess_line_num + 1;
102                            let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
103                            let (start_line, start_col, end_line, end_col) =
104                                calculate_line_range(excess_line, excess_line_content);
105                            warnings.push(LintWarning {
106                                rule_name: Some(self.name()),
107                                severity: Severity::Warning,
108                                message: format!("Multiple consecutive blank lines {location}"),
109                                line: start_line,
110                                column: start_col,
111                                end_line,
112                                end_column: end_col,
113                                fix: Some(Fix {
114                                    range: {
115                                        let line_start = _line_index.get_line_start_byte(excess_line).unwrap_or(0);
116                                        let line_end = _line_index
117                                            .get_line_start_byte(excess_line + 1)
118                                            .unwrap_or(line_start + 1);
119                                        line_start..line_end
120                                    },
121                                    replacement: String::new(),
122                                }),
123                            });
124                        }
125                    }
126                }
127                blank_count = 0;
128                lines_to_check.clear();
129                continue;
130            }
131
132            // Check for code block boundaries
133            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
134                // Check for excess blanks before entering/exiting code block
135                if blank_count > self.config.maximum {
136                    let location = if blank_start == 0 {
137                        "at start of file"
138                    } else {
139                        "between content"
140                    };
141                    for i in self.config.maximum..blank_count {
142                        let excess_line_num = blank_start + i;
143                        if lines_to_check.contains(&excess_line_num) {
144                            let excess_line = excess_line_num + 1;
145                            let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
146                            let (start_line, start_col, end_line, end_col) =
147                                calculate_line_range(excess_line, excess_line_content);
148                            warnings.push(LintWarning {
149                                rule_name: Some(self.name()),
150                                severity: Severity::Warning,
151                                message: format!("Multiple consecutive blank lines {location}"),
152                                line: start_line,
153                                column: start_col,
154                                end_line,
155                                end_column: end_col,
156                                fix: Some(Fix {
157                                    range: {
158                                        let line_start = _line_index.get_line_start_byte(excess_line).unwrap_or(0);
159                                        let line_end = _line_index
160                                            .get_line_start_byte(excess_line + 1)
161                                            .unwrap_or(line_start + 1);
162                                        line_start..line_end
163                                    },
164                                    replacement: String::new(),
165                                }),
166                            });
167                        }
168                    }
169                }
170
171                if !in_code_block {
172                    // Entering code block
173                    in_code_block = true;
174                    code_fence_marker = if trimmed.starts_with("```") { "```" } else { "~~~" };
175                } else if trimmed.starts_with(code_fence_marker) {
176                    // Exiting code block
177                    in_code_block = false;
178                    code_fence_marker = "";
179                }
180                blank_count = 0;
181                lines_to_check.clear();
182                continue;
183            }
184
185            // Skip lines in code blocks or front matter
186            if in_code_block || in_front_matter {
187                // Reset counter to prevent counting across boundaries
188                blank_count = 0;
189                continue;
190            }
191
192            // Check for indented code blocks (4+ spaces)
193            let is_indented_code = line.len() >= 4 && line.starts_with("    ") && !line.trim().is_empty();
194            if is_indented_code {
195                // Check for excess blanks before indented code block
196                if blank_count > self.config.maximum {
197                    let location = if blank_start == 0 {
198                        "at start of file"
199                    } else {
200                        "between content"
201                    };
202                    for i in self.config.maximum..blank_count {
203                        let excess_line_num = blank_start + i;
204                        if lines_to_check.contains(&excess_line_num) {
205                            let excess_line = excess_line_num + 1;
206                            let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
207                            let (start_line, start_col, end_line, end_col) =
208                                calculate_line_range(excess_line, excess_line_content);
209                            warnings.push(LintWarning {
210                                rule_name: Some(self.name()),
211                                severity: Severity::Warning,
212                                message: format!("Multiple consecutive blank lines {location}"),
213                                line: start_line,
214                                column: start_col,
215                                end_line,
216                                end_column: end_col,
217                                fix: Some(Fix {
218                                    range: {
219                                        let line_start = _line_index.get_line_start_byte(excess_line).unwrap_or(0);
220                                        let line_end = _line_index
221                                            .get_line_start_byte(excess_line + 1)
222                                            .unwrap_or(line_start + 1);
223                                        line_start..line_end
224                                    },
225                                    replacement: String::new(),
226                                }),
227                            });
228                        }
229                    }
230                }
231                blank_count = 0;
232                lines_to_check.clear();
233                continue;
234            }
235
236            if line.trim().is_empty() {
237                if blank_count == 0 {
238                    blank_start = line_num;
239                }
240                blank_count += 1;
241                // Store line numbers that exceed the limit
242                if blank_count > self.config.maximum {
243                    lines_to_check.insert(line_num);
244                }
245            } else {
246                if blank_count > self.config.maximum {
247                    // Generate warnings for each excess blank line
248                    let location = if blank_start == 0 {
249                        "at start of file"
250                    } else {
251                        "between content"
252                    };
253
254                    // Report warnings starting from the (maximum+1)th blank line
255                    for i in self.config.maximum..blank_count {
256                        let excess_line_num = blank_start + i;
257                        if lines_to_check.contains(&excess_line_num) {
258                            let excess_line = excess_line_num + 1; // +1 for 1-indexed lines
259                            let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
260
261                            // Calculate precise character range for the entire blank line
262                            let (start_line, start_col, end_line, end_col) =
263                                calculate_line_range(excess_line, excess_line_content);
264
265                            warnings.push(LintWarning {
266                                rule_name: Some(self.name()),
267                                severity: Severity::Warning,
268                                message: format!("Multiple consecutive blank lines {location}"),
269                                line: start_line,
270                                column: start_col,
271                                end_line,
272                                end_column: end_col,
273                                fix: Some(Fix {
274                                    range: {
275                                        // Remove entire line including newline
276                                        let line_start = _line_index.get_line_start_byte(excess_line).unwrap_or(0);
277                                        let line_end = _line_index
278                                            .get_line_start_byte(excess_line + 1)
279                                            .unwrap_or(line_start + 1);
280                                        line_start..line_end
281                                    },
282                                    replacement: String::new(), // Remove the excess line
283                                }),
284                            });
285                        }
286                    }
287                }
288                blank_count = 0;
289                lines_to_check.clear();
290            }
291        }
292
293        // Check for trailing blank lines
294        // Special handling: lines() doesn't create an empty string for a final trailing newline
295        // So we need to check the raw content for multiple trailing newlines
296
297        // Count consecutive newlines at the end of the file
298        let mut consecutive_newlines_at_end: usize = 0;
299        for ch in content.chars().rev() {
300            if ch == '\n' {
301                consecutive_newlines_at_end += 1;
302            } else if ch == '\r' {
303                // Skip carriage returns in CRLF
304                continue;
305            } else {
306                break;
307            }
308        }
309
310        // Markdownlint treats ANY blank lines at EOF as a violation
311        // A file ending with \n\n has 1 blank line but markdownlint reports it
312        // as "multiple consecutive blank lines" with "Actual: 2"
313        // This suggests it counts blank lines at EOF specially
314        let blank_lines_at_eof = consecutive_newlines_at_end.saturating_sub(1);
315
316        // At EOF, ANY blank lines violate the rule (markdownlint behavior)
317        // This is different from the middle of the file where 1 blank is allowed
318        if blank_lines_at_eof > 0 {
319            let location = "at end of file";
320
321            // Report on the last line (which is blank)
322            let report_line = lines.len();
323
324            // Report one warning for the excess blank lines at EOF
325            warnings.push(LintWarning {
326                rule_name: Some(self.name()),
327                severity: Severity::Warning,
328                message: format!("Multiple consecutive blank lines {location}"),
329                line: report_line,
330                column: 1,
331                end_line: report_line,
332                end_column: 1,
333                fix: Some(Fix {
334                    range: {
335                        // Remove excess trailing newlines
336                        // Keep content up to where excess newlines start
337                        let excess_newlines = blank_lines_at_eof - self.config.maximum;
338                        let keep_chars = content.len() - excess_newlines;
339                        keep_chars..content.len()
340                    },
341                    replacement: String::new(),
342                }),
343            });
344        }
345
346        Ok(warnings)
347    }
348
349    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
350        let content = ctx.content;
351        let _line_index = LineIndex::new(content.to_string());
352
353        let mut result = Vec::new();
354
355        let mut blank_count = 0;
356
357        let lines: Vec<&str> = content.lines().collect();
358
359        let mut in_code_block = false;
360
361        let mut in_front_matter = false;
362
363        let mut code_block_blanks = Vec::new();
364
365        for &line in lines.iter() {
366            // Track code blocks and front matter
367            if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
368                // Handle accumulated blank lines before code block
369                if !in_code_block {
370                    let allowed_blanks = blank_count.min(self.config.maximum);
371                    if allowed_blanks > 0 {
372                        result.extend(vec![""; allowed_blanks]);
373                    }
374                    blank_count = 0;
375                } else {
376                    // Add accumulated blank lines inside code block
377                    result.append(&mut code_block_blanks);
378                }
379                in_code_block = !in_code_block;
380                result.push(line);
381                continue;
382            }
383
384            if line.trim() == "---" {
385                in_front_matter = !in_front_matter;
386                if blank_count > 0 {
387                    result.extend(vec![""; blank_count]);
388                    blank_count = 0;
389                }
390                result.push(line);
391                continue;
392            }
393
394            if in_code_block {
395                if line.trim().is_empty() {
396                    code_block_blanks.push(line);
397                } else {
398                    result.append(&mut code_block_blanks);
399                    result.push(line);
400                }
401            } else if in_front_matter {
402                if blank_count > 0 {
403                    result.extend(vec![""; blank_count]);
404                    blank_count = 0;
405                }
406                result.push(line);
407            } else if line.trim().is_empty() {
408                blank_count += 1;
409            } else {
410                // Add allowed blank lines before content
411                let allowed_blanks = blank_count.min(self.config.maximum);
412                if allowed_blanks > 0 {
413                    result.extend(vec![""; allowed_blanks]);
414                }
415                blank_count = 0;
416                result.push(line);
417            }
418        }
419
420        // Handle trailing blank lines
421        if !in_code_block {
422            let allowed_blanks = blank_count.min(self.config.maximum);
423            if allowed_blanks > 0 {
424                result.extend(vec![""; allowed_blanks]);
425            }
426        }
427
428        // Join lines and handle final newline
429
430        let mut output = result.join("\n");
431        if content.ends_with('\n') {
432            output.push('\n');
433        }
434
435        Ok(output)
436    }
437
438    fn as_any(&self) -> &dyn std::any::Any {
439        self
440    }
441
442    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
443        // Skip if content is empty or doesn't have consecutive newlines
444        ctx.content.is_empty() || !ctx.content.contains("\n\n")
445    }
446
447    fn default_config_section(&self) -> Option<(String, toml::Value)> {
448        let default_config = MD012Config::default();
449        let json_value = serde_json::to_value(&default_config).ok()?;
450        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
451
452        if let toml::Value::Table(table) = toml_value {
453            if !table.is_empty() {
454                Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
455            } else {
456                None
457            }
458        } else {
459            None
460        }
461    }
462
463    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
464    where
465        Self: Sized,
466    {
467        let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
468        Box::new(Self::from_config_struct(rule_config))
469    }
470}
471
472impl crate::utils::document_structure::DocumentStructureExtensions for MD012NoMultipleBlanks {
473    fn has_relevant_elements(
474        &self,
475        ctx: &crate::lint_context::LintContext,
476        _structure: &crate::utils::document_structure::DocumentStructure,
477    ) -> bool {
478        // MD012 checks for consecutive blank lines, so it's relevant for any non-empty content
479        !ctx.content.is_empty()
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use crate::lint_context::LintContext;
487
488    #[test]
489    fn test_single_blank_line_allowed() {
490        let rule = MD012NoMultipleBlanks::default();
491        let content = "Line 1\n\nLine 2\n\nLine 3";
492        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
493        let result = rule.check(&ctx).unwrap();
494        assert!(result.is_empty());
495    }
496
497    #[test]
498    fn test_multiple_blank_lines_flagged() {
499        let rule = MD012NoMultipleBlanks::default();
500        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
501        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
502        let result = rule.check(&ctx).unwrap();
503        assert_eq!(result.len(), 3); // 1 extra in first gap, 2 extra in second gap
504        assert_eq!(result[0].line, 3);
505        assert_eq!(result[1].line, 6);
506        assert_eq!(result[2].line, 7);
507    }
508
509    #[test]
510    fn test_custom_maximum() {
511        let rule = MD012NoMultipleBlanks::new(2);
512        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
513        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
514        let result = rule.check(&ctx).unwrap();
515        assert_eq!(result.len(), 1); // Only the fourth blank line is excessive
516        assert_eq!(result[0].line, 7);
517    }
518
519    #[test]
520    fn test_fix_multiple_blank_lines() {
521        let rule = MD012NoMultipleBlanks::default();
522        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
523        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
524        let fixed = rule.fix(&ctx).unwrap();
525        assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
526    }
527
528    #[test]
529    fn test_blank_lines_in_code_block() {
530        let rule = MD012NoMultipleBlanks::default();
531        let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
532        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
533        let result = rule.check(&ctx).unwrap();
534        assert!(result.is_empty()); // Blank lines inside code blocks are ignored
535    }
536
537    #[test]
538    fn test_fix_preserves_code_block_blanks() {
539        let rule = MD012NoMultipleBlanks::default();
540        let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
541        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
542        let fixed = rule.fix(&ctx).unwrap();
543        assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
544    }
545
546    #[test]
547    fn test_blank_lines_in_front_matter() {
548        let rule = MD012NoMultipleBlanks::default();
549        let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
550        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
551        let result = rule.check(&ctx).unwrap();
552        assert!(result.is_empty()); // Blank lines in front matter are ignored
553    }
554
555    #[test]
556    fn test_blank_lines_at_start() {
557        let rule = MD012NoMultipleBlanks::default();
558        let content = "\n\n\nContent";
559        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
560        let result = rule.check(&ctx).unwrap();
561        assert_eq!(result.len(), 2);
562        assert!(result[0].message.contains("at start of file"));
563    }
564
565    #[test]
566    fn test_blank_lines_at_end() {
567        let rule = MD012NoMultipleBlanks::default();
568        let content = "Content\n\n\n";
569        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
570        let result = rule.check(&ctx).unwrap();
571        assert_eq!(result.len(), 1);
572        assert!(result[0].message.contains("at end of file"));
573    }
574
575    #[test]
576    fn test_single_blank_at_eof_flagged() {
577        // Markdownlint behavior: ANY blank lines at EOF are flagged
578        let rule = MD012NoMultipleBlanks::default();
579        let content = "Content\n\n";
580        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
581        let result = rule.check(&ctx).unwrap();
582        assert_eq!(result.len(), 1);
583        assert!(result[0].message.contains("at end of file"));
584    }
585
586    #[test]
587    fn test_whitespace_only_lines() {
588        let rule = MD012NoMultipleBlanks::default();
589        let content = "Line 1\n  \n\t\nLine 2";
590        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
591        let result = rule.check(&ctx).unwrap();
592        assert_eq!(result.len(), 1); // Whitespace-only lines count as blank
593    }
594
595    #[test]
596    fn test_indented_code_blocks() {
597        let rule = MD012NoMultipleBlanks::default();
598        let content = "Text\n\n    code\n    \n    \n    more code\n\nText";
599        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
600        let result = rule.check(&ctx).unwrap();
601        // The recent changes to MD012 now detect blank lines even in indented code blocks
602        // This is actually correct behavior - we should flag the extra blank line
603        assert_eq!(result.len(), 1);
604        assert_eq!(result[0].line, 5); // Line 5 is the second blank line in the code block
605    }
606
607    #[test]
608    fn test_fix_with_final_newline() {
609        let rule = MD012NoMultipleBlanks::default();
610        let content = "Line 1\n\n\nLine 2\n";
611        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
612        let fixed = rule.fix(&ctx).unwrap();
613        assert_eq!(fixed, "Line 1\n\nLine 2\n");
614        assert!(fixed.ends_with('\n'));
615    }
616
617    #[test]
618    fn test_empty_content() {
619        let rule = MD012NoMultipleBlanks::default();
620        let content = "";
621        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
622        let result = rule.check(&ctx).unwrap();
623        assert!(result.is_empty());
624    }
625
626    #[test]
627    fn test_nested_code_blocks() {
628        let rule = MD012NoMultipleBlanks::default();
629        let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
630        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
631        let result = rule.check(&ctx).unwrap();
632        assert!(result.is_empty());
633    }
634
635    #[test]
636    fn test_unclosed_code_block() {
637        let rule = MD012NoMultipleBlanks::default();
638        let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
639        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
640        let result = rule.check(&ctx).unwrap();
641        assert!(result.is_empty()); // Unclosed code blocks still preserve blank lines
642    }
643
644    #[test]
645    fn test_mixed_fence_styles() {
646        let rule = MD012NoMultipleBlanks::default();
647        let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
648        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
649        let result = rule.check(&ctx).unwrap();
650        assert!(result.is_empty()); // Mixed fence styles should work
651    }
652
653    #[test]
654    fn test_config_from_toml() {
655        let mut config = crate::config::Config::default();
656        let mut rule_config = crate::config::RuleConfig::default();
657        rule_config
658            .values
659            .insert("maximum".to_string(), toml::Value::Integer(3));
660        config.rules.insert("MD012".to_string(), rule_config);
661
662        let rule = MD012NoMultipleBlanks::from_config(&config);
663        let content = "Line 1\n\n\n\nLine 2"; // 3 blank lines
664        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
665        let result = rule.check(&ctx).unwrap();
666        assert!(result.is_empty()); // 3 blank lines allowed with maximum=3
667    }
668
669    #[test]
670    fn test_blank_lines_between_sections() {
671        let rule = MD012NoMultipleBlanks::default();
672        let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
673        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
674        let result = rule.check(&ctx).unwrap();
675        assert_eq!(result.len(), 1);
676        assert_eq!(result[0].line, 5);
677    }
678
679    #[test]
680    fn test_fix_preserves_indented_code() {
681        let rule = MD012NoMultipleBlanks::default();
682        let content = "Text\n\n\n    code\n    \n    more code\n\n\nText";
683        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
684        let fixed = rule.fix(&ctx).unwrap();
685        // The fix removes the extra blank line, but this is expected behavior
686        assert_eq!(fixed, "Text\n\n    code\n\n    more code\n\nText");
687    }
688
689    #[test]
690    fn test_edge_case_only_blanks() {
691        let rule = MD012NoMultipleBlanks::default();
692        let content = "\n\n\n";
693        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
694        let result = rule.check(&ctx).unwrap();
695        // With the new EOF handling, we report once at EOF
696        assert_eq!(result.len(), 1);
697        assert!(result[0].message.contains("at end of file"));
698    }
699}