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