Skip to main content

rumdl_lib/rules/
md012_no_multiple_blanks.rs

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