Skip to main content

rumdl_lib/rules/
md012_no_multiple_blanks.rs

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