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