Skip to main content

rumdl_lib/rules/
md012_no_multiple_blanks.rs

1use crate::filtered_lines::FilteredLinesExt;
2use crate::lint_context::LintContext;
3use crate::lint_context::types::HeadingStyle;
4use crate::utils::LineIndex;
5use crate::utils::range_utils::calculate_line_range;
6use std::collections::HashSet;
7
8use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
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)]
18pub struct MD012NoMultipleBlanks {
19    config: MD012Config,
20    /// Maximum blank lines allowed adjacent to headings (above).
21    /// Derived from MD022's lines-above config to avoid conflicts.
22    heading_blanks_above: usize,
23    /// Maximum blank lines allowed adjacent to headings (below).
24    /// Derived from MD022's lines-below config to avoid conflicts.
25    heading_blanks_below: usize,
26}
27
28impl Default for MD012NoMultipleBlanks {
29    fn default() -> Self {
30        Self {
31            config: MD012Config::default(),
32            heading_blanks_above: 1,
33            heading_blanks_below: 1,
34        }
35    }
36}
37
38impl MD012NoMultipleBlanks {
39    pub fn new(maximum: usize) -> Self {
40        use crate::types::PositiveUsize;
41        Self {
42            config: MD012Config {
43                maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
44            },
45            heading_blanks_above: 1,
46            heading_blanks_below: 1,
47        }
48    }
49
50    pub const fn from_config_struct(config: MD012Config) -> Self {
51        Self {
52            config,
53            heading_blanks_above: 1,
54            heading_blanks_below: 1,
55        }
56    }
57
58    /// Set heading blank line limits derived from MD022 config.
59    /// `above` and `below` are the maximum blank lines MD022 allows above/below headings.
60    pub fn with_heading_limits(mut self, above: usize, below: usize) -> Self {
61        self.heading_blanks_above = above;
62        self.heading_blanks_below = below;
63        self
64    }
65
66    /// The effective maximum blank lines allowed for heading-adjacent runs.
67    /// Returns the larger of MD012's own maximum and the relevant MD022 limit,
68    /// so MD012 never flags blanks that MD022 requires.
69    fn effective_max_above(&self) -> usize {
70        self.config.maximum.get().max(self.heading_blanks_above)
71    }
72
73    fn effective_max_below(&self) -> usize {
74        self.config.maximum.get().max(self.heading_blanks_below)
75    }
76
77    /// Generate warnings for excess blank lines beyond the given maximum.
78    fn generate_excess_warnings(
79        &self,
80        blank_start: usize,
81        blank_count: usize,
82        effective_max: usize,
83        lines: &[&str],
84        lines_to_check: &HashSet<usize>,
85        line_index: &LineIndex,
86    ) -> Vec<LintWarning> {
87        let mut warnings = Vec::new();
88
89        let location = if blank_start == 0 {
90            "at start of file"
91        } else {
92            "between content"
93        };
94
95        for i in effective_max..blank_count {
96            let excess_line_num = blank_start + i;
97            if lines_to_check.contains(&excess_line_num) {
98                let excess_line = excess_line_num + 1;
99                let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
100                let (start_line, start_col, end_line, end_col) = calculate_line_range(excess_line, excess_line_content);
101                warnings.push(LintWarning {
102                    rule_name: Some(self.name().to_string()),
103                    severity: Severity::Warning,
104                    message: format!("Multiple consecutive blank lines {location}"),
105                    line: start_line,
106                    column: start_col,
107                    end_line,
108                    end_column: end_col,
109                    fix: Some(Fix::new(
110                        {
111                            let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
112                            let line_end = line_index
113                                .get_line_start_byte(excess_line + 1)
114                                .unwrap_or(line_start + 1);
115                            line_start..line_end
116                        },
117                        String::new(),
118                    )),
119                });
120            }
121        }
122
123        warnings
124    }
125}
126
127/// Check if the given 0-based line index is part of a heading.
128///
129/// Returns true if:
130/// - The line has heading info (covers ATX headings and Setext text lines), OR
131/// - The previous line is a Setext heading text line (covers the Setext underline)
132fn is_heading_context(ctx: &LintContext, line_idx: usize) -> bool {
133    if ctx.lines.get(line_idx).is_some_and(|li| li.heading.is_some()) {
134        return true;
135    }
136    // Check if previous line is a Setext heading text line — if so, this line is the underline
137    if line_idx > 0
138        && let Some(prev_info) = ctx.lines.get(line_idx - 1)
139        && let Some(ref heading) = prev_info.heading
140        && matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2)
141    {
142        return true;
143    }
144    false
145}
146
147/// Extract the maximum blank line requirement across all heading levels.
148/// Returns `usize::MAX` if any level is Unlimited (-1), since MD012 should
149/// never flag blanks that MD022 permits unconditionally.
150fn max_heading_limit(
151    level_config: &crate::rules::md022_blanks_around_headings::md022_config::HeadingLevelConfig,
152) -> usize {
153    let mut max_val: usize = 0;
154    for level in 1..=6 {
155        match level_config.get_for_level(level).required_count() {
156            None => return usize::MAX, // Unlimited: MD012 should never flag
157            Some(count) => max_val = max_val.max(count),
158        }
159    }
160    max_val
161}
162
163impl Rule for MD012NoMultipleBlanks {
164    fn name(&self) -> &'static str {
165        "MD012"
166    }
167
168    fn description(&self) -> &'static str {
169        "Multiple consecutive blank lines"
170    }
171
172    fn category(&self) -> RuleCategory {
173        RuleCategory::Whitespace
174    }
175
176    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
177        let content = ctx.content;
178
179        // Early return for empty content
180        if content.is_empty() {
181            return Ok(Vec::new());
182        }
183
184        // Quick check for consecutive newlines or potential whitespace-only lines before processing
185        // Look for multiple consecutive lines that could be blank (empty or whitespace-only)
186        let lines = ctx.raw_lines();
187        let has_potential_blanks = lines
188            .windows(2)
189            .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
190
191        // Also check for blanks at EOF (markdownlint behavior)
192        // Content is normalized to LF at I/O boundary
193        let ends_with_multiple_newlines = content.ends_with("\n\n");
194
195        if !has_potential_blanks && !ends_with_multiple_newlines {
196            return Ok(Vec::new());
197        }
198
199        let line_index = &ctx.line_index;
200
201        let mut warnings = Vec::new();
202
203        // Single-pass algorithm with immediate counter reset
204        let mut blank_count = 0;
205        let mut blank_start = 0;
206        let mut last_line_num: Option<usize> = None;
207        // Track the last non-blank content line for heading adjacency checks
208        let mut prev_content_line_num: Option<usize> = None;
209
210        // Use HashSet for O(1) lookups of lines that need to be checked
211        let mut lines_to_check: HashSet<usize> = HashSet::new();
212
213        // Use filtered_lines to automatically skip front-matter, code blocks, Quarto divs, math blocks,
214        // PyMdown blocks, and Obsidian comments.
215        // The in_code_block field in LineInfo is pre-computed using pulldown-cmark
216        // and correctly handles both fenced code blocks and indented code blocks.
217        // Flavor-specific fields (in_pandoc_div, in_pymdown_block, in_obsidian_comment) are only
218        // set for their respective flavors, so the skip filters have no effect otherwise.
219        for filtered_line in ctx
220            .filtered_lines()
221            .skip_front_matter()
222            .skip_code_blocks()
223            .skip_html_comments()
224            .skip_html_blocks()
225            .skip_quarto_divs()
226            .skip_math_blocks()
227            .skip_obsidian_comments()
228            .skip_pymdown_blocks()
229            .skip_jsx_expressions()
230            .skip_mdx_comments()
231            .skip_jsx_blocks()
232            .skip_esm_blocks()
233        {
234            let line_num = filtered_line.line_num - 1; // Convert 1-based to 0-based for internal tracking
235            let line = filtered_line.content;
236
237            // Detect when lines were skipped (e.g., code block content)
238            // If we jump more than 1 line, there was content between, which breaks blank sequences
239            if let Some(last) = last_line_num
240                && line_num > last + 1
241            {
242                // Lines were skipped (code block or similar)
243                // Generate warnings for any accumulated blanks before the skip
244                let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
245                    self.effective_max_below()
246                } else {
247                    self.config.maximum.get()
248                };
249                if blank_count > effective_max {
250                    warnings.extend(self.generate_excess_warnings(
251                        blank_start,
252                        blank_count,
253                        effective_max,
254                        lines,
255                        &lines_to_check,
256                        line_index,
257                    ));
258                }
259                blank_count = 0;
260                lines_to_check.clear();
261                // Reset heading context across skipped regions (code blocks, etc.)
262                prev_content_line_num = None;
263            }
264            last_line_num = Some(line_num);
265
266            if line.trim().is_empty() {
267                if blank_count == 0 {
268                    blank_start = line_num;
269                }
270                blank_count += 1;
271                // Store line numbers that exceed the limit
272                if blank_count > self.config.maximum.get() {
273                    lines_to_check.insert(line_num);
274                }
275            } else {
276                // Determine effective maximum for this blank run.
277                // Heading-adjacent blanks use the higher of MD012's maximum
278                // and MD022's required blank lines, so MD012 doesn't conflict.
279                // Start-of-file blanks (blank_start == 0) before a heading use
280                // the normal maximum — no rule requires blanks at file start.
281                let heading_below = prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx));
282                let heading_above = blank_start > 0 && is_heading_context(ctx, line_num);
283                let effective_max = if heading_below && heading_above {
284                    // Between two headings: use the larger of above/below limits
285                    self.effective_max_above().max(self.effective_max_below())
286                } else if heading_below {
287                    self.effective_max_below()
288                } else if heading_above {
289                    self.effective_max_above()
290                } else {
291                    self.config.maximum.get()
292                };
293
294                if blank_count > effective_max {
295                    warnings.extend(self.generate_excess_warnings(
296                        blank_start,
297                        blank_count,
298                        effective_max,
299                        lines,
300                        &lines_to_check,
301                        line_index,
302                    ));
303                }
304                blank_count = 0;
305                lines_to_check.clear();
306                prev_content_line_num = Some(line_num);
307            }
308        }
309
310        // Handle trailing blanks at EOF
311        // Main loop only reports mid-document blanks (between content)
312        // EOF handler reports trailing blanks with stricter rules (any blank at EOF is flagged)
313        //
314        // The blank_count at end of loop might include blanks BEFORE a code block at EOF,
315        // which aren't truly "trailing blanks". We need to verify the actual last line is blank.
316        let last_line_is_blank = lines.last().is_some_and(|l| l.trim().is_empty());
317
318        // Blanks left over before a skipped region that runs to EOF are mid-document
319        // blanks, so they use the same limits as the skip handling inside the loop.
320        if blank_count > 0 && !last_line_is_blank {
321            let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
322                self.effective_max_below()
323            } else {
324                self.config.maximum.get()
325            };
326            if blank_count > effective_max {
327                warnings.extend(self.generate_excess_warnings(
328                    blank_start,
329                    blank_count,
330                    effective_max,
331                    lines,
332                    &lines_to_check,
333                    line_index,
334                ));
335            }
336        }
337
338        // Check for trailing blank lines
339        // EOF semantics: ANY blank line at EOF should be flagged (stricter than mid-document)
340        // Only fire if the actual last line(s) of the file are blank
341        if blank_count > 0 && last_line_is_blank {
342            let location = "at end of file";
343
344            // Report on the last line (which is blank)
345            let report_line = lines.len();
346
347            // Calculate fix: remove all trailing blank lines
348            // Find where the trailing blanks start (blank_count tells us how many consecutive blanks)
349            let fix_start = line_index
350                .get_line_start_byte(report_line - blank_count + 1)
351                .unwrap_or(0);
352            let fix_end = content.len();
353
354            // Report one warning for the excess blank lines at EOF
355            warnings.push(LintWarning {
356                rule_name: Some(self.name().to_string()),
357                severity: Severity::Warning,
358                message: format!("Multiple consecutive blank lines {location}"),
359                line: report_line,
360                column: 1,
361                end_line: report_line,
362                end_column: 1,
363                fix: Some(Fix::new(fix_start..fix_end, String::new())),
364            });
365        }
366
367        Ok(warnings)
368    }
369
370    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
371        if self.should_skip(ctx) {
372            return Ok(ctx.content.to_string());
373        }
374        let warnings = self.check(ctx)?;
375        if warnings.is_empty() {
376            return Ok(ctx.content.to_string());
377        }
378        let warnings =
379            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
380        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
381            .map_err(crate::rule::LintError::InvalidInput)
382    }
383
384    fn as_any(&self) -> &dyn std::any::Any {
385        self
386    }
387
388    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
389        // Skip if content is empty or doesn't have newlines (single line can't have multiple blanks)
390        ctx.content.is_empty() || !ctx.has_char('\n')
391    }
392
393    crate::impl_rule_config_sections!(MD012Config);
394
395    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
396    where
397        Self: Sized,
398    {
399        use crate::rules::md022_blanks_around_headings::md022_config::MD022Config;
400
401        let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
402
403        // Read MD022 config to determine heading blank line limits.
404        // If MD022 is disabled, don't apply special heading limits.
405        let md022_disabled = config.global.disable.iter().any(|r| r == "MD022")
406            || config.global.extend_disable.iter().any(|r| r == "MD022");
407
408        let (heading_above, heading_below) = if md022_disabled {
409            // MD022 disabled: no special heading treatment, use MD012's own maximum
410            (rule_config.maximum.get(), rule_config.maximum.get())
411        } else {
412            let md022_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
413            (
414                max_heading_limit(&md022_config.lines_above),
415                max_heading_limit(&md022_config.lines_below),
416            )
417        };
418
419        Box::new(Self {
420            config: rule_config,
421            heading_blanks_above: heading_above,
422            heading_blanks_below: heading_below,
423        })
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::lint_context::LintContext;
431
432    #[test]
433    fn test_single_blank_line_allowed() {
434        let rule = MD012NoMultipleBlanks::default();
435        let content = "Line 1\n\nLine 2\n\nLine 3";
436        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
437        let result = rule.check(&ctx).unwrap();
438        assert!(result.is_empty());
439    }
440
441    #[test]
442    fn test_multiple_blank_lines_flagged() {
443        let rule = MD012NoMultipleBlanks::default();
444        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
445        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446        let result = rule.check(&ctx).unwrap();
447        assert_eq!(result.len(), 3); // 1 extra in first gap, 2 extra in second gap
448        assert_eq!(result[0].line, 3);
449        assert_eq!(result[1].line, 6);
450        assert_eq!(result[2].line, 7);
451    }
452
453    #[test]
454    fn test_custom_maximum() {
455        let rule = MD012NoMultipleBlanks::new(2);
456        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
457        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
458        let result = rule.check(&ctx).unwrap();
459        assert_eq!(result.len(), 1); // Only the fourth blank line is excessive
460        assert_eq!(result[0].line, 7);
461    }
462
463    #[test]
464    fn test_fix_multiple_blank_lines() {
465        let rule = MD012NoMultipleBlanks::default();
466        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
467        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
468        let fixed = rule.fix(&ctx).unwrap();
469        assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
470    }
471
472    #[test]
473    fn test_blank_lines_in_code_block() {
474        let rule = MD012NoMultipleBlanks::default();
475        let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
476        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
477        let result = rule.check(&ctx).unwrap();
478        assert!(result.is_empty()); // Blank lines inside code blocks are ignored
479    }
480
481    #[test]
482    fn test_blank_lines_in_html_comment() {
483        let rule = MD012NoMultipleBlanks::default();
484        let content = "Before\n\n<!--\ncomment\n\n\n\nmore comment\n-->\n\nAfter";
485        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
486        let result = rule.check(&ctx).unwrap();
487        assert!(result.is_empty()); // Blank lines inside HTML comments are ignored
488    }
489
490    #[test]
491    fn test_blank_lines_in_html_block() {
492        let rule = MD012NoMultipleBlanks::default();
493        let content = "Before\n\n<script>\n\n\n\n</script>\n\nAfter";
494        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
495        let result = rule.check(&ctx).unwrap();
496        assert!(result.is_empty()); // Blank lines inside HTML blocks are ignored
497    }
498
499    #[test]
500    fn test_fix_preserves_code_block_blanks() {
501        let rule = MD012NoMultipleBlanks::default();
502        let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
503        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
504        let fixed = rule.fix(&ctx).unwrap();
505        assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
506    }
507
508    #[test]
509    fn test_blank_lines_in_front_matter() {
510        let rule = MD012NoMultipleBlanks::default();
511        let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
512        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
513        let result = rule.check(&ctx).unwrap();
514        assert!(result.is_empty()); // Blank lines in front matter are ignored
515    }
516
517    #[test]
518    fn test_blank_lines_at_start() {
519        let rule = MD012NoMultipleBlanks::default();
520        let content = "\n\n\nContent";
521        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
522        let result = rule.check(&ctx).unwrap();
523        assert_eq!(result.len(), 2);
524        assert!(result[0].message.contains("at start of file"));
525    }
526
527    #[test]
528    fn test_blank_lines_at_end() {
529        let rule = MD012NoMultipleBlanks::default();
530        let content = "Content\n\n\n";
531        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532        let result = rule.check(&ctx).unwrap();
533        assert_eq!(result.len(), 1);
534        assert!(result[0].message.contains("at end of file"));
535    }
536
537    #[test]
538    fn test_single_blank_at_eof_flagged() {
539        // Markdownlint behavior: ANY blank lines at EOF are flagged
540        let rule = MD012NoMultipleBlanks::default();
541        let content = "Content\n\n";
542        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543        let result = rule.check(&ctx).unwrap();
544        assert_eq!(result.len(), 1);
545        assert!(result[0].message.contains("at end of file"));
546    }
547
548    #[test]
549    fn test_whitespace_only_lines() {
550        let rule = MD012NoMultipleBlanks::default();
551        let content = "Line 1\n  \n\t\nLine 2";
552        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
553        let result = rule.check(&ctx).unwrap();
554        assert_eq!(result.len(), 1); // Whitespace-only lines count as blank
555    }
556
557    #[test]
558    fn test_indented_code_blocks() {
559        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
560        let rule = MD012NoMultipleBlanks::default();
561        let content = "Text\n\n    code\n    \n    \n    more code\n\nText";
562        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563        let result = rule.check(&ctx).unwrap();
564        assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
565    }
566
567    #[test]
568    fn test_blanks_in_indented_code_block() {
569        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
570        let content = "    code line 1\n\n\n    code line 2\n";
571        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
572        let rule = MD012NoMultipleBlanks::default();
573        let warnings = rule.check(&ctx).unwrap();
574        assert!(warnings.is_empty(), "Should not flag blanks in indented code");
575    }
576
577    #[test]
578    fn test_blanks_in_indented_code_block_with_heading() {
579        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
580        let content = "# Heading\n\n    code line 1\n\n\n    code line 2\n\nMore text\n";
581        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582        let rule = MD012NoMultipleBlanks::default();
583        let warnings = rule.check(&ctx).unwrap();
584        assert!(
585            warnings.is_empty(),
586            "Should not flag blanks in indented code after heading"
587        );
588    }
589
590    #[test]
591    fn test_blanks_after_indented_code_block_flagged() {
592        // Blanks AFTER an indented code block end should still be flagged
593        let content = "# Heading\n\n    code line\n\n\n\nMore text\n";
594        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595        let rule = MD012NoMultipleBlanks::default();
596        let warnings = rule.check(&ctx).unwrap();
597        // There are 3 blank lines after the code block, so 2 extra should be flagged
598        assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
599    }
600
601    #[test]
602    fn test_fix_with_final_newline() {
603        let rule = MD012NoMultipleBlanks::default();
604        let content = "Line 1\n\n\nLine 2\n";
605        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606        let fixed = rule.fix(&ctx).unwrap();
607        assert_eq!(fixed, "Line 1\n\nLine 2\n");
608        assert!(fixed.ends_with('\n'));
609    }
610
611    #[test]
612    fn test_empty_content() {
613        let rule = MD012NoMultipleBlanks::default();
614        let content = "";
615        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
616        let result = rule.check(&ctx).unwrap();
617        assert!(result.is_empty());
618    }
619
620    #[test]
621    fn test_nested_code_blocks() {
622        let rule = MD012NoMultipleBlanks::default();
623        let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
624        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625        let result = rule.check(&ctx).unwrap();
626        assert!(result.is_empty());
627    }
628
629    #[test]
630    fn test_unclosed_code_block() {
631        let rule = MD012NoMultipleBlanks::default();
632        let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
633        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
634        let result = rule.check(&ctx).unwrap();
635        assert!(result.is_empty()); // Unclosed code blocks still preserve blank lines
636    }
637
638    #[test]
639    fn test_mixed_fence_styles() {
640        let rule = MD012NoMultipleBlanks::default();
641        let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
642        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643        let result = rule.check(&ctx).unwrap();
644        assert!(result.is_empty()); // Mixed fence styles should work
645    }
646
647    #[test]
648    fn test_config_from_toml() {
649        let mut config = crate::config::Config::default();
650        let mut rule_config = crate::config::RuleConfig::default();
651        rule_config
652            .values
653            .insert("maximum".to_string(), toml::Value::Integer(3));
654        config.rules.insert("MD012".to_string(), rule_config);
655
656        let rule = MD012NoMultipleBlanks::from_config(&config);
657        let content = "Line 1\n\n\n\nLine 2"; // 3 blank lines
658        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659        let result = rule.check(&ctx).unwrap();
660        assert!(result.is_empty()); // 3 blank lines allowed with maximum=3
661    }
662
663    #[test]
664    fn test_blank_lines_between_sections() {
665        // With heading limits from MD022, heading-adjacent excess is allowed up to the limit
666        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
667        let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
668        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669        let result = rule.check(&ctx).unwrap();
670        assert!(
671            result.is_empty(),
672            "2 blanks above heading allowed with heading_blanks_above=2"
673        );
674    }
675
676    #[test]
677    fn test_fix_preserves_indented_code() {
678        let rule = MD012NoMultipleBlanks::default();
679        let content = "Text\n\n\n    code\n    \n    more code\n\n\nText";
680        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
681        let fixed = rule.fix(&ctx).unwrap();
682        // Fix removes excess blank lines outside code blocks but preserves
683        // whitespace-only lines inside indented code blocks unchanged.
684        assert_eq!(fixed, "Text\n\n    code\n    \n    more code\n\nText");
685    }
686
687    #[test]
688    fn test_edge_case_only_blanks() {
689        let rule = MD012NoMultipleBlanks::default();
690        let content = "\n\n\n";
691        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692        let result = rule.check(&ctx).unwrap();
693        // With the new EOF handling, we report once at EOF
694        assert_eq!(result.len(), 1);
695        assert!(result[0].message.contains("at end of file"));
696    }
697
698    // Regression tests for blanks after code blocks (GitHub issue #199 related)
699
700    #[test]
701    fn test_blanks_after_fenced_code_block_mid_document() {
702        // Blanks between code block and heading use heading_above limit
703        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
704        let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
705        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
706        let result = rule.check(&ctx).unwrap();
707        assert!(
708            result.is_empty(),
709            "2 blanks before heading allowed with heading_blanks_above=2"
710        );
711    }
712
713    #[test]
714    fn test_blanks_after_code_block_at_eof() {
715        // Trailing blanks after code block at end of file
716        let rule = MD012NoMultipleBlanks::default();
717        let content = "# Heading\n\n```\ncode\n```\n\n\n";
718        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
719        let result = rule.check(&ctx).unwrap();
720        // Should flag the trailing blanks at EOF
721        assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
722        assert!(result[0].message.contains("at end of file"));
723    }
724
725    #[test]
726    fn test_single_blank_after_code_block_allowed() {
727        // Single blank after code block is allowed (default max=1)
728        let rule = MD012NoMultipleBlanks::default();
729        let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
730        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
731        let result = rule.check(&ctx).unwrap();
732        assert!(result.is_empty(), "Single blank after code block should be allowed");
733    }
734
735    #[test]
736    fn test_multiple_code_blocks_with_blanks() {
737        // Multiple code blocks, each followed by blanks
738        let rule = MD012NoMultipleBlanks::default();
739        let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
740        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741        let result = rule.check(&ctx).unwrap();
742        // Should flag both double-blank sequences
743        assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
744    }
745
746    #[test]
747    fn test_whitespace_only_lines_after_code_block_at_eof() {
748        // Whitespace-only lines (not just empty) after code block at EOF
749        // This matches the React repo pattern where lines have trailing spaces
750        let rule = MD012NoMultipleBlanks::default();
751        let content = "```\ncode\n```\n   \n   \n";
752        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
753        let result = rule.check(&ctx).unwrap();
754        assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
755        assert!(result[0].message.contains("at end of file"));
756    }
757
758    // Tests for warning-based fix (used by LSP formatting)
759
760    #[test]
761    fn test_warning_fix_removes_single_trailing_blank() {
762        // Regression test for issue #265: LSP formatting should work for EOF blanks
763        let rule = MD012NoMultipleBlanks::default();
764        let content = "hello foobar hello.\n\n";
765        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
766        let warnings = rule.check(&ctx).unwrap();
767
768        assert_eq!(warnings.len(), 1);
769        assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
770
771        let fix = warnings[0].fix.as_ref().unwrap();
772        // The fix should remove the trailing blank line
773        assert_eq!(fix.replacement, "", "Replacement should be empty");
774
775        // Apply the fix and verify result
776        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
777        assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
778    }
779
780    #[test]
781    fn test_warning_fix_removes_multiple_trailing_blanks() {
782        let rule = MD012NoMultipleBlanks::default();
783        let content = "content\n\n\n\n";
784        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785        let warnings = rule.check(&ctx).unwrap();
786
787        assert_eq!(warnings.len(), 1);
788        assert!(warnings[0].fix.is_some());
789
790        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
791        assert_eq!(fixed, "content\n", "Should end with single newline");
792    }
793
794    #[test]
795    fn test_warning_fix_preserves_content_newline() {
796        // Ensure the fix doesn't remove the content line's trailing newline
797        let rule = MD012NoMultipleBlanks::default();
798        let content = "line1\nline2\n\n";
799        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800        let warnings = rule.check(&ctx).unwrap();
801
802        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
803        assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
804    }
805
806    #[test]
807    fn test_warning_fix_mid_document_blanks() {
808        // With default limits (1,1), heading-adjacent excess blanks are flagged
809        let rule = MD012NoMultipleBlanks::default();
810        let content = "# Heading\n\n\n\nParagraph\n";
811        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812        let warnings = rule.check(&ctx).unwrap();
813        assert_eq!(
814            warnings.len(),
815            2,
816            "Excess heading-adjacent blanks flagged with default limits"
817        );
818    }
819
820    // Heading awareness tests
821    // MD012 reads MD022's config to determine heading blank line limits.
822    // When MD022 requires N blank lines around headings, MD012 allows up to N.
823
824    #[test]
825    fn test_heading_aware_blanks_below_with_higher_limit() {
826        // With heading_blanks_below = 2, 2 blanks below heading are allowed
827        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
828        let content = "# Heading\n\n\nParagraph\n";
829        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
830        let result = rule.check(&ctx).unwrap();
831        assert!(
832            result.is_empty(),
833            "2 blanks below heading allowed with heading_blanks_below=2"
834        );
835    }
836
837    #[test]
838    fn test_heading_aware_blanks_above_with_higher_limit() {
839        // With heading_blanks_above = 2, 2 blanks above heading are allowed
840        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
841        let content = "Paragraph\n\n\n# Heading\n";
842        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
843        let result = rule.check(&ctx).unwrap();
844        assert!(
845            result.is_empty(),
846            "2 blanks above heading allowed with heading_blanks_above=2"
847        );
848    }
849
850    #[test]
851    fn test_heading_aware_blanks_between_headings() {
852        // Between headings, use the larger of above/below limits
853        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
854        let content = "# Heading 1\n\n\n## Heading 2\n";
855        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856        let result = rule.check(&ctx).unwrap();
857        assert!(result.is_empty(), "2 blanks between headings allowed with limits=2");
858    }
859
860    #[test]
861    fn test_heading_aware_excess_still_flagged() {
862        // Even with heading limits, excess beyond the limit is flagged
863        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
864        let content = "# Heading\n\n\n\n\nParagraph\n"; // 4 blanks, limit is 2
865        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866        let result = rule.check(&ctx).unwrap();
867        assert_eq!(result.len(), 2, "Excess beyond heading limit should be flagged");
868    }
869
870    #[test]
871    fn test_heading_aware_setext_blanks_below() {
872        // Setext headings with heading limits
873        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
874        let content = "Heading\n=======\n\n\nParagraph\n";
875        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876        let result = rule.check(&ctx).unwrap();
877        assert!(result.is_empty(), "2 blanks below Setext heading allowed with limit=2");
878    }
879
880    #[test]
881    fn test_heading_aware_setext_blanks_above() {
882        // Setext headings with heading limits
883        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
884        let content = "Paragraph\n\n\nHeading\n=======\n";
885        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886        let result = rule.check(&ctx).unwrap();
887        assert!(result.is_empty(), "2 blanks above Setext heading allowed with limit=2");
888    }
889
890    #[test]
891    fn test_heading_aware_single_blank_allowed() {
892        // 1 blank near heading is always allowed
893        let rule = MD012NoMultipleBlanks::default();
894        let content = "# Heading\n\nParagraph\n";
895        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896        let result = rule.check(&ctx).unwrap();
897        assert!(result.is_empty(), "Single blank near heading should be allowed");
898    }
899
900    #[test]
901    fn test_heading_aware_non_heading_blanks_still_flagged() {
902        // Blanks between non-heading content should still be flagged
903        let rule = MD012NoMultipleBlanks::default();
904        let content = "Paragraph 1\n\n\nParagraph 2\n";
905        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
906        let result = rule.check(&ctx).unwrap();
907        assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
908    }
909
910    #[test]
911    fn test_heading_aware_fix_caps_heading_blanks() {
912        // MD012 fix caps heading-adjacent blanks at effective max
913        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
914        let content = "# Heading\n\n\n\nParagraph\n"; // 3 blanks, limit below is 2
915        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916        let fixed = rule.fix(&ctx).unwrap();
917        assert_eq!(
918            fixed, "# Heading\n\n\nParagraph\n",
919            "Fix caps heading-adjacent blanks at effective max (2)"
920        );
921    }
922
923    #[test]
924    fn test_heading_aware_fix_preserves_allowed_heading_blanks() {
925        // When blanks are within the heading limit, fix preserves them
926        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 3);
927        let content = "# Heading\n\n\n\nParagraph\n"; // 3 blanks, limit below is 3
928        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
929        let fixed = rule.fix(&ctx).unwrap();
930        assert_eq!(
931            fixed, "# Heading\n\n\n\nParagraph\n",
932            "Fix preserves blanks within the heading limit"
933        );
934    }
935
936    #[test]
937    fn test_heading_aware_fix_reduces_non_heading_blanks() {
938        // Fix should still reduce non-heading blanks
939        let rule = MD012NoMultipleBlanks::default();
940        let content = "Paragraph 1\n\n\n\nParagraph 2\n";
941        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
942        let fixed = rule.fix(&ctx).unwrap();
943        assert_eq!(
944            fixed, "Paragraph 1\n\nParagraph 2\n",
945            "Fix should reduce non-heading blanks"
946        );
947    }
948
949    #[test]
950    fn test_heading_aware_mixed_heading_and_non_heading() {
951        // With heading limits, heading-adjacent gaps use higher limit
952        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
953        let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
954        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955        let result = rule.check(&ctx).unwrap();
956        // heading->para gap (2 blanks, limit=2): ok. para->para gap (2 blanks, limit=1): flagged
957        assert_eq!(result.len(), 1, "Only non-heading excess should be flagged");
958    }
959
960    #[test]
961    fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
962        // Start-of-file blanks are always flagged, even before a heading.
963        // No rule requires blanks at the absolute start of a file.
964        let rule = MD012NoMultipleBlanks::default().with_heading_limits(3, 3);
965        let content = "\n\n\n# Heading\n";
966        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
967        let result = rule.check(&ctx).unwrap();
968        assert_eq!(
969            result.len(),
970            2,
971            "Start-of-file blanks should be flagged even before heading"
972        );
973        assert!(result[0].message.contains("at start of file"));
974    }
975
976    #[test]
977    fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
978        // EOF blanks should still be flagged even after a heading
979        let rule = MD012NoMultipleBlanks::default();
980        let content = "# Heading\n\n";
981        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
982        let result = rule.check(&ctx).unwrap();
983        assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
984        assert!(result[0].message.contains("at end of file"));
985    }
986
987    #[test]
988    fn test_heading_aware_unlimited_heading_blanks() {
989        // With usize::MAX heading limit (Unlimited in MD022), MD012 never flags heading-adjacent
990        let rule = MD012NoMultipleBlanks::default().with_heading_limits(usize::MAX, usize::MAX);
991        let content = "# Heading\n\n\n\n\nParagraph\n"; // 4 blanks below heading
992        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993        let result = rule.check(&ctx).unwrap();
994        assert!(
995            result.is_empty(),
996            "Unlimited heading limits means MD012 never flags near headings"
997        );
998    }
999
1000    #[test]
1001    fn test_heading_aware_blanks_after_code_then_heading() {
1002        // Blanks after code block are not heading-adjacent (prev_content_line_num reset)
1003        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1004        let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
1005        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1006        let result = rule.check(&ctx).unwrap();
1007        // The blanks are between code block and "More text" (not heading-adjacent)
1008        assert_eq!(result.len(), 2, "Non-heading blanks after code block should be flagged");
1009    }
1010
1011    #[test]
1012    fn test_heading_aware_fix_mixed_document() {
1013        // MD012 fix with heading limits
1014        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1015        let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
1016        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1017        let fixed = rule.fix(&ctx).unwrap();
1018        // Heading-adjacent blanks preserved (within limit=2), non-heading blanks reduced
1019        assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
1020    }
1021
1022    #[test]
1023    fn test_heading_aware_from_config_reads_md022() {
1024        // from_config reads MD022 config to determine heading limits
1025        let mut config = crate::config::Config::default();
1026        let mut md022_config = crate::config::RuleConfig::default();
1027        md022_config
1028            .values
1029            .insert("lines-above".to_string(), toml::Value::Integer(2));
1030        md022_config
1031            .values
1032            .insert("lines-below".to_string(), toml::Value::Integer(3));
1033        config.rules.insert("MD022".to_string(), md022_config);
1034
1035        let rule = MD012NoMultipleBlanks::from_config(&config);
1036        // With MD022 lines-above=2: 2 blanks above heading should be allowed
1037        let content = "Paragraph\n\n\n# Heading\n";
1038        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039        let result = rule.check(&ctx).unwrap();
1040        assert!(
1041            result.is_empty(),
1042            "2 blanks above heading allowed when MD022 lines-above=2"
1043        );
1044    }
1045
1046    #[test]
1047    fn test_heading_aware_from_config_md022_disabled() {
1048        // When MD022 is disabled, MD012 uses its own maximum everywhere
1049        let mut config = crate::config::Config::default();
1050        config.global.disable.push("MD022".to_string());
1051
1052        let mut md022_config = crate::config::RuleConfig::default();
1053        md022_config
1054            .values
1055            .insert("lines-above".to_string(), toml::Value::Integer(3));
1056        config.rules.insert("MD022".to_string(), md022_config);
1057
1058        let rule = MD012NoMultipleBlanks::from_config(&config);
1059        // MD022 disabled: heading-adjacent blanks treated like any other
1060        let content = "Paragraph\n\n\n# Heading\n";
1061        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1062        let result = rule.check(&ctx).unwrap();
1063        assert_eq!(
1064            result.len(),
1065            1,
1066            "With MD022 disabled, heading-adjacent blanks are flagged"
1067        );
1068    }
1069
1070    #[test]
1071    fn test_heading_aware_from_config_md022_unlimited() {
1072        // When MD022 has lines-above = -1 (Unlimited), MD012 never flags above headings
1073        let mut config = crate::config::Config::default();
1074        let mut md022_config = crate::config::RuleConfig::default();
1075        md022_config
1076            .values
1077            .insert("lines-above".to_string(), toml::Value::Integer(-1));
1078        config.rules.insert("MD022".to_string(), md022_config);
1079
1080        let rule = MD012NoMultipleBlanks::from_config(&config);
1081        let content = "Paragraph\n\n\n\n\n# Heading\n"; // 4 blanks above heading
1082        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1083        let result = rule.check(&ctx).unwrap();
1084        assert!(
1085            result.is_empty(),
1086            "Unlimited MD022 lines-above means MD012 never flags above headings"
1087        );
1088    }
1089
1090    #[test]
1091    fn test_heading_aware_from_config_per_level() {
1092        // Per-level config: max_heading_limit takes the maximum across all levels.
1093        // lines-above = [2, 1, 1, 1, 1, 1] → heading_blanks_above = 2 (max of all levels).
1094        // This means 2 blanks above ANY heading is allowed, even if only H1 needs 2.
1095        // This is a deliberate trade-off: conservative (no false positives from MD012).
1096        let mut config = crate::config::Config::default();
1097        let mut md022_config = crate::config::RuleConfig::default();
1098        md022_config.values.insert(
1099            "lines-above".to_string(),
1100            toml::Value::Array(vec![
1101                toml::Value::Integer(2),
1102                toml::Value::Integer(1),
1103                toml::Value::Integer(1),
1104                toml::Value::Integer(1),
1105                toml::Value::Integer(1),
1106                toml::Value::Integer(1),
1107            ]),
1108        );
1109        config.rules.insert("MD022".to_string(), md022_config);
1110
1111        let rule = MD012NoMultipleBlanks::from_config(&config);
1112
1113        // 2 blanks above H2: MD012 allows it (max across levels is 2)
1114        let content = "Paragraph\n\n\n## H2 Heading\n";
1115        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1116        let result = rule.check(&ctx).unwrap();
1117        assert!(result.is_empty(), "Per-level max (2) allows 2 blanks above any heading");
1118
1119        // 3 blanks above H2: exceeds the per-level max of 2
1120        let content = "Paragraph\n\n\n\n## H2 Heading\n";
1121        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1122        let result = rule.check(&ctx).unwrap();
1123        assert_eq!(result.len(), 1, "3 blanks exceeds per-level max of 2");
1124    }
1125
1126    #[test]
1127    fn test_issue_449_reproduction() {
1128        // Exact reproduction case from GitHub issue #449.
1129        // With default settings, excess blanks around headings should be flagged.
1130        let rule = MD012NoMultipleBlanks::default();
1131        let content = "\
1132# Heading
1133
1134
1135Some introductory text.
1136
1137
1138
1139
1140
1141## Heading level 2
1142
1143
1144Some text for this section.
1145
1146Some more text for this section.
1147
1148
1149## Another heading level 2
1150
1151
1152
1153Some text for this section.
1154
1155Some more text for this section.
1156";
1157        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158        let result = rule.check(&ctx).unwrap();
1159        assert!(
1160            !result.is_empty(),
1161            "Issue #449: excess blanks around headings should be flagged with default settings"
1162        );
1163
1164        // Verify fix produces clean output
1165        let fixed = rule.fix(&ctx).unwrap();
1166        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1167        let recheck = rule.check(&fixed_ctx).unwrap();
1168        assert!(recheck.is_empty(), "Fix should resolve all excess blank lines");
1169
1170        // Verify the fixed output has exactly 1 blank line around each heading
1171        assert!(fixed.contains("# Heading\n\nSome"), "1 blank below first heading");
1172        assert!(
1173            fixed.contains("text.\n\n## Heading level 2"),
1174            "1 blank above second heading"
1175        );
1176    }
1177
1178    // Quarto flavor tests
1179
1180    #[test]
1181    fn test_blank_lines_in_quarto_callout() {
1182        // Blank lines inside Quarto callout blocks should be allowed
1183        let rule = MD012NoMultipleBlanks::default();
1184        let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
1185        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1186        let result = rule.check(&ctx).unwrap();
1187        assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
1188    }
1189
1190    #[test]
1191    fn test_blank_lines_in_quarto_div() {
1192        // Blank lines inside generic Quarto divs should be allowed
1193        let rule = MD012NoMultipleBlanks::default();
1194        let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
1195        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1196        let result = rule.check(&ctx).unwrap();
1197        assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
1198    }
1199
1200    #[test]
1201    fn test_blank_lines_outside_quarto_div_flagged() {
1202        // Blank lines outside Quarto divs should still be flagged
1203        let rule = MD012NoMultipleBlanks::default();
1204        let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
1205        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1206        let result = rule.check(&ctx).unwrap();
1207        assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1208    }
1209
1210    #[test]
1211    fn test_quarto_divs_ignored_in_standard_flavor() {
1212        // In standard flavor, Quarto div syntax is not special
1213        let rule = MD012NoMultipleBlanks::default();
1214        let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1215        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1216        let result = rule.check(&ctx).unwrap();
1217        // In standard flavor, the triple blank inside "div" is flagged
1218        assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1219    }
1220
1221    // Roundtrip safety tests: fix then re-check = 0 violations
1222
1223    #[test]
1224    fn test_roundtrip_multiple_blank_lines() {
1225        let rule = MD012NoMultipleBlanks::default();
1226        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
1227        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1228        let fixed = rule.fix(&ctx).unwrap();
1229        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1230        let recheck = rule.check(&ctx2).unwrap();
1231        assert!(
1232            recheck.is_empty(),
1233            "Roundtrip: fix then check should be clean, got {recheck:?}"
1234        );
1235    }
1236
1237    #[test]
1238    fn test_roundtrip_trailing_blanks() {
1239        let rule = MD012NoMultipleBlanks::default();
1240        let content = "Content\n\n\n\n";
1241        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242        let fixed = rule.fix(&ctx).unwrap();
1243        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1244        let recheck = rule.check(&ctx2).unwrap();
1245        assert!(recheck.is_empty(), "Roundtrip: trailing blanks, got {recheck:?}");
1246    }
1247
1248    #[test]
1249    fn test_roundtrip_leading_blanks() {
1250        let rule = MD012NoMultipleBlanks::default();
1251        let content = "\n\n\nContent\n";
1252        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1253        let fixed = rule.fix(&ctx).unwrap();
1254        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1255        let recheck = rule.check(&ctx2).unwrap();
1256        assert!(recheck.is_empty(), "Roundtrip: leading blanks, got {recheck:?}");
1257    }
1258
1259    #[test]
1260    fn test_roundtrip_custom_maximum() {
1261        let rule = MD012NoMultipleBlanks::new(2);
1262        let content = "Line 1\n\n\n\n\nLine 2\n";
1263        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1264        let fixed = rule.fix(&ctx).unwrap();
1265        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1266        let recheck = rule.check(&ctx2).unwrap();
1267        assert!(recheck.is_empty(), "Roundtrip: max=2, got {recheck:?}");
1268    }
1269
1270    #[test]
1271    fn test_roundtrip_code_blocks() {
1272        let rule = MD012NoMultipleBlanks::default();
1273        let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
1274        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1275        let fixed = rule.fix(&ctx).unwrap();
1276        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1277        let recheck = rule.check(&ctx2).unwrap();
1278        assert!(recheck.is_empty(), "Roundtrip: code blocks, got {recheck:?}");
1279    }
1280
1281    #[test]
1282    fn test_roundtrip_heading_limits() {
1283        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1284        let content = "# Heading\n\n\n\n\nParagraph\n\n\n\n## Heading 2\n";
1285        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1286        let fixed = rule.fix(&ctx).unwrap();
1287        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1288        let recheck = rule.check(&ctx2).unwrap();
1289        assert!(recheck.is_empty(), "Roundtrip: heading limits, got {recheck:?}");
1290    }
1291
1292    #[test]
1293    fn test_roundtrip_front_matter() {
1294        let rule = MD012NoMultipleBlanks::default();
1295        let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\n\n\nContent\n";
1296        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297        let fixed = rule.fix(&ctx).unwrap();
1298        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1299        let recheck = rule.check(&ctx2).unwrap();
1300        assert!(recheck.is_empty(), "Roundtrip: front matter, got {recheck:?}");
1301    }
1302
1303    #[test]
1304    fn test_roundtrip_only_blanks() {
1305        let rule = MD012NoMultipleBlanks::default();
1306        let content = "\n\n\n";
1307        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1308        let fixed = rule.fix(&ctx).unwrap();
1309        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1310        let recheck = rule.check(&ctx2).unwrap();
1311        assert!(recheck.is_empty(), "Roundtrip: only blanks, got {recheck:?}");
1312    }
1313
1314    #[test]
1315    fn test_roundtrip_single_eof_blank() {
1316        let rule = MD012NoMultipleBlanks::default();
1317        let content = "Content\n\n";
1318        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1319        let fixed = rule.fix(&ctx).unwrap();
1320        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1321        let recheck = rule.check(&ctx2).unwrap();
1322        assert!(recheck.is_empty(), "Roundtrip: single EOF blank, got {recheck:?}");
1323    }
1324
1325    #[test]
1326    fn test_roundtrip_mixed_heading_and_non_heading() {
1327        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
1328        let content = "# Heading\n\n\n\nParagraph 1\n\n\n\nParagraph 2\n";
1329        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1330        let fixed = rule.fix(&ctx).unwrap();
1331        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1332        let recheck = rule.check(&ctx2).unwrap();
1333        assert!(
1334            recheck.is_empty(),
1335            "Roundtrip: mixed heading/non-heading, got {recheck:?}"
1336        );
1337    }
1338}