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