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