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_quarto_divs()
225            .skip_math_blocks()
226            .skip_obsidian_comments()
227            .skip_pymdown_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                        line_index,
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                        line_index,
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        // Check for trailing blank lines
314        // EOF semantics: ANY blank line at EOF should be flagged (stricter than mid-document)
315        // Only fire if the actual last line(s) of the file are blank
316        if blank_count > 0 && last_line_is_blank {
317            let location = "at end of file";
318
319            // Report on the last line (which is blank)
320            let report_line = lines.len();
321
322            // Calculate fix: remove all trailing blank lines
323            // Find where the trailing blanks start (blank_count tells us how many consecutive blanks)
324            let fix_start = line_index
325                .get_line_start_byte(report_line - blank_count + 1)
326                .unwrap_or(0);
327            let fix_end = content.len();
328
329            // Report one warning for the excess blank lines at EOF
330            warnings.push(LintWarning {
331                rule_name: Some(self.name().to_string()),
332                severity: Severity::Warning,
333                message: format!("Multiple consecutive blank lines {location}"),
334                line: report_line,
335                column: 1,
336                end_line: report_line,
337                end_column: 1,
338                fix: Some(Fix::new(fix_start..fix_end, String::new())),
339            });
340        }
341
342        Ok(warnings)
343    }
344
345    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
346        if self.should_skip(ctx) {
347            return Ok(ctx.content.to_string());
348        }
349        let warnings = self.check(ctx)?;
350        if warnings.is_empty() {
351            return Ok(ctx.content.to_string());
352        }
353        let warnings =
354            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
355        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
356            .map_err(crate::rule::LintError::InvalidInput)
357    }
358
359    fn as_any(&self) -> &dyn std::any::Any {
360        self
361    }
362
363    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
364        // Skip if content is empty or doesn't have newlines (single line can't have multiple blanks)
365        ctx.content.is_empty() || !ctx.has_char('\n')
366    }
367
368    fn default_config_section(&self) -> Option<(String, toml::Value)> {
369        let default_config = MD012Config::default();
370        let json_value = serde_json::to_value(&default_config).ok()?;
371        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
372
373        if let toml::Value::Table(table) = toml_value {
374            if !table.is_empty() {
375                Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
376            } else {
377                None
378            }
379        } else {
380            None
381        }
382    }
383
384    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
385    where
386        Self: Sized,
387    {
388        use crate::rules::md022_blanks_around_headings::md022_config::MD022Config;
389
390        let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
391
392        // Read MD022 config to determine heading blank line limits.
393        // If MD022 is disabled, don't apply special heading limits.
394        let md022_disabled = config.global.disable.iter().any(|r| r == "MD022")
395            || config.global.extend_disable.iter().any(|r| r == "MD022");
396
397        let (heading_above, heading_below) = if md022_disabled {
398            // MD022 disabled: no special heading treatment, use MD012's own maximum
399            (rule_config.maximum.get(), rule_config.maximum.get())
400        } else {
401            let md022_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
402            (
403                max_heading_limit(&md022_config.lines_above),
404                max_heading_limit(&md022_config.lines_below),
405            )
406        };
407
408        Box::new(Self {
409            config: rule_config,
410            heading_blanks_above: heading_above,
411            heading_blanks_below: heading_below,
412        })
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::lint_context::LintContext;
420
421    #[test]
422    fn test_single_blank_line_allowed() {
423        let rule = MD012NoMultipleBlanks::default();
424        let content = "Line 1\n\nLine 2\n\nLine 3";
425        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426        let result = rule.check(&ctx).unwrap();
427        assert!(result.is_empty());
428    }
429
430    #[test]
431    fn test_multiple_blank_lines_flagged() {
432        let rule = MD012NoMultipleBlanks::default();
433        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
434        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
435        let result = rule.check(&ctx).unwrap();
436        assert_eq!(result.len(), 3); // 1 extra in first gap, 2 extra in second gap
437        assert_eq!(result[0].line, 3);
438        assert_eq!(result[1].line, 6);
439        assert_eq!(result[2].line, 7);
440    }
441
442    #[test]
443    fn test_custom_maximum() {
444        let rule = MD012NoMultipleBlanks::new(2);
445        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
446        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
447        let result = rule.check(&ctx).unwrap();
448        assert_eq!(result.len(), 1); // Only the fourth blank line is excessive
449        assert_eq!(result[0].line, 7);
450    }
451
452    #[test]
453    fn test_fix_multiple_blank_lines() {
454        let rule = MD012NoMultipleBlanks::default();
455        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
456        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
457        let fixed = rule.fix(&ctx).unwrap();
458        assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
459    }
460
461    #[test]
462    fn test_blank_lines_in_code_block() {
463        let rule = MD012NoMultipleBlanks::default();
464        let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
465        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
466        let result = rule.check(&ctx).unwrap();
467        assert!(result.is_empty()); // Blank lines inside code blocks are ignored
468    }
469
470    #[test]
471    fn test_fix_preserves_code_block_blanks() {
472        let rule = MD012NoMultipleBlanks::default();
473        let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
474        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
475        let fixed = rule.fix(&ctx).unwrap();
476        assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
477    }
478
479    #[test]
480    fn test_blank_lines_in_front_matter() {
481        let rule = MD012NoMultipleBlanks::default();
482        let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
483        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
484        let result = rule.check(&ctx).unwrap();
485        assert!(result.is_empty()); // Blank lines in front matter are ignored
486    }
487
488    #[test]
489    fn test_blank_lines_at_start() {
490        let rule = MD012NoMultipleBlanks::default();
491        let content = "\n\n\nContent";
492        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
493        let result = rule.check(&ctx).unwrap();
494        assert_eq!(result.len(), 2);
495        assert!(result[0].message.contains("at start of file"));
496    }
497
498    #[test]
499    fn test_blank_lines_at_end() {
500        let rule = MD012NoMultipleBlanks::default();
501        let content = "Content\n\n\n";
502        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
503        let result = rule.check(&ctx).unwrap();
504        assert_eq!(result.len(), 1);
505        assert!(result[0].message.contains("at end of file"));
506    }
507
508    #[test]
509    fn test_single_blank_at_eof_flagged() {
510        // Markdownlint behavior: ANY blank lines at EOF are flagged
511        let rule = MD012NoMultipleBlanks::default();
512        let content = "Content\n\n";
513        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
514        let result = rule.check(&ctx).unwrap();
515        assert_eq!(result.len(), 1);
516        assert!(result[0].message.contains("at end of file"));
517    }
518
519    #[test]
520    fn test_whitespace_only_lines() {
521        let rule = MD012NoMultipleBlanks::default();
522        let content = "Line 1\n  \n\t\nLine 2";
523        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524        let result = rule.check(&ctx).unwrap();
525        assert_eq!(result.len(), 1); // Whitespace-only lines count as blank
526    }
527
528    #[test]
529    fn test_indented_code_blocks() {
530        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
531        let rule = MD012NoMultipleBlanks::default();
532        let content = "Text\n\n    code\n    \n    \n    more code\n\nText";
533        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
534        let result = rule.check(&ctx).unwrap();
535        assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
536    }
537
538    #[test]
539    fn test_blanks_in_indented_code_block() {
540        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
541        let content = "    code line 1\n\n\n    code line 2\n";
542        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543        let rule = MD012NoMultipleBlanks::default();
544        let warnings = rule.check(&ctx).unwrap();
545        assert!(warnings.is_empty(), "Should not flag blanks in indented code");
546    }
547
548    #[test]
549    fn test_blanks_in_indented_code_block_with_heading() {
550        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
551        let content = "# Heading\n\n    code line 1\n\n\n    code line 2\n\nMore text\n";
552        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
553        let rule = MD012NoMultipleBlanks::default();
554        let warnings = rule.check(&ctx).unwrap();
555        assert!(
556            warnings.is_empty(),
557            "Should not flag blanks in indented code after heading"
558        );
559    }
560
561    #[test]
562    fn test_blanks_after_indented_code_block_flagged() {
563        // Blanks AFTER an indented code block end should still be flagged
564        let content = "# Heading\n\n    code line\n\n\n\nMore text\n";
565        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
566        let rule = MD012NoMultipleBlanks::default();
567        let warnings = rule.check(&ctx).unwrap();
568        // There are 3 blank lines after the code block, so 2 extra should be flagged
569        assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
570    }
571
572    #[test]
573    fn test_fix_with_final_newline() {
574        let rule = MD012NoMultipleBlanks::default();
575        let content = "Line 1\n\n\nLine 2\n";
576        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
577        let fixed = rule.fix(&ctx).unwrap();
578        assert_eq!(fixed, "Line 1\n\nLine 2\n");
579        assert!(fixed.ends_with('\n'));
580    }
581
582    #[test]
583    fn test_empty_content() {
584        let rule = MD012NoMultipleBlanks::default();
585        let content = "";
586        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587        let result = rule.check(&ctx).unwrap();
588        assert!(result.is_empty());
589    }
590
591    #[test]
592    fn test_nested_code_blocks() {
593        let rule = MD012NoMultipleBlanks::default();
594        let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
595        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596        let result = rule.check(&ctx).unwrap();
597        assert!(result.is_empty());
598    }
599
600    #[test]
601    fn test_unclosed_code_block() {
602        let rule = MD012NoMultipleBlanks::default();
603        let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
604        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605        let result = rule.check(&ctx).unwrap();
606        assert!(result.is_empty()); // Unclosed code blocks still preserve blank lines
607    }
608
609    #[test]
610    fn test_mixed_fence_styles() {
611        let rule = MD012NoMultipleBlanks::default();
612        let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
613        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614        let result = rule.check(&ctx).unwrap();
615        assert!(result.is_empty()); // Mixed fence styles should work
616    }
617
618    #[test]
619    fn test_config_from_toml() {
620        let mut config = crate::config::Config::default();
621        let mut rule_config = crate::config::RuleConfig::default();
622        rule_config
623            .values
624            .insert("maximum".to_string(), toml::Value::Integer(3));
625        config.rules.insert("MD012".to_string(), rule_config);
626
627        let rule = MD012NoMultipleBlanks::from_config(&config);
628        let content = "Line 1\n\n\n\nLine 2"; // 3 blank lines
629        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630        let result = rule.check(&ctx).unwrap();
631        assert!(result.is_empty()); // 3 blank lines allowed with maximum=3
632    }
633
634    #[test]
635    fn test_blank_lines_between_sections() {
636        // With heading limits from MD022, heading-adjacent excess is allowed up to the limit
637        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
638        let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
639        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
640        let result = rule.check(&ctx).unwrap();
641        assert!(
642            result.is_empty(),
643            "2 blanks above heading allowed with heading_blanks_above=2"
644        );
645    }
646
647    #[test]
648    fn test_fix_preserves_indented_code() {
649        let rule = MD012NoMultipleBlanks::default();
650        let content = "Text\n\n\n    code\n    \n    more code\n\n\nText";
651        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652        let fixed = rule.fix(&ctx).unwrap();
653        // Fix removes excess blank lines outside code blocks but preserves
654        // whitespace-only lines inside indented code blocks unchanged.
655        assert_eq!(fixed, "Text\n\n    code\n    \n    more code\n\nText");
656    }
657
658    #[test]
659    fn test_edge_case_only_blanks() {
660        let rule = MD012NoMultipleBlanks::default();
661        let content = "\n\n\n";
662        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
663        let result = rule.check(&ctx).unwrap();
664        // With the new EOF handling, we report once at EOF
665        assert_eq!(result.len(), 1);
666        assert!(result[0].message.contains("at end of file"));
667    }
668
669    // Regression tests for blanks after code blocks (GitHub issue #199 related)
670
671    #[test]
672    fn test_blanks_after_fenced_code_block_mid_document() {
673        // Blanks between code block and heading use heading_above limit
674        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
675        let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
676        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677        let result = rule.check(&ctx).unwrap();
678        assert!(
679            result.is_empty(),
680            "2 blanks before heading allowed with heading_blanks_above=2"
681        );
682    }
683
684    #[test]
685    fn test_blanks_after_code_block_at_eof() {
686        // Trailing blanks after code block at end of file
687        let rule = MD012NoMultipleBlanks::default();
688        let content = "# Heading\n\n```\ncode\n```\n\n\n";
689        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690        let result = rule.check(&ctx).unwrap();
691        // Should flag the trailing blanks at EOF
692        assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
693        assert!(result[0].message.contains("at end of file"));
694    }
695
696    #[test]
697    fn test_single_blank_after_code_block_allowed() {
698        // Single blank after code block is allowed (default max=1)
699        let rule = MD012NoMultipleBlanks::default();
700        let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
701        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
702        let result = rule.check(&ctx).unwrap();
703        assert!(result.is_empty(), "Single blank after code block should be allowed");
704    }
705
706    #[test]
707    fn test_multiple_code_blocks_with_blanks() {
708        // Multiple code blocks, each followed by blanks
709        let rule = MD012NoMultipleBlanks::default();
710        let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
711        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712        let result = rule.check(&ctx).unwrap();
713        // Should flag both double-blank sequences
714        assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
715    }
716
717    #[test]
718    fn test_whitespace_only_lines_after_code_block_at_eof() {
719        // Whitespace-only lines (not just empty) after code block at EOF
720        // This matches the React repo pattern where lines have trailing spaces
721        let rule = MD012NoMultipleBlanks::default();
722        let content = "```\ncode\n```\n   \n   \n";
723        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
724        let result = rule.check(&ctx).unwrap();
725        assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
726        assert!(result[0].message.contains("at end of file"));
727    }
728
729    // Tests for warning-based fix (used by LSP formatting)
730
731    #[test]
732    fn test_warning_fix_removes_single_trailing_blank() {
733        // Regression test for issue #265: LSP formatting should work for EOF blanks
734        let rule = MD012NoMultipleBlanks::default();
735        let content = "hello foobar hello.\n\n";
736        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737        let warnings = rule.check(&ctx).unwrap();
738
739        assert_eq!(warnings.len(), 1);
740        assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
741
742        let fix = warnings[0].fix.as_ref().unwrap();
743        // The fix should remove the trailing blank line
744        assert_eq!(fix.replacement, "", "Replacement should be empty");
745
746        // Apply the fix and verify result
747        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
748        assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
749    }
750
751    #[test]
752    fn test_warning_fix_removes_multiple_trailing_blanks() {
753        let rule = MD012NoMultipleBlanks::default();
754        let content = "content\n\n\n\n";
755        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756        let warnings = rule.check(&ctx).unwrap();
757
758        assert_eq!(warnings.len(), 1);
759        assert!(warnings[0].fix.is_some());
760
761        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
762        assert_eq!(fixed, "content\n", "Should end with single newline");
763    }
764
765    #[test]
766    fn test_warning_fix_preserves_content_newline() {
767        // Ensure the fix doesn't remove the content line's trailing newline
768        let rule = MD012NoMultipleBlanks::default();
769        let content = "line1\nline2\n\n";
770        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771        let warnings = rule.check(&ctx).unwrap();
772
773        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
774        assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
775    }
776
777    #[test]
778    fn test_warning_fix_mid_document_blanks() {
779        // With default limits (1,1), heading-adjacent excess blanks are flagged
780        let rule = MD012NoMultipleBlanks::default();
781        let content = "# Heading\n\n\n\nParagraph\n";
782        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783        let warnings = rule.check(&ctx).unwrap();
784        assert_eq!(
785            warnings.len(),
786            2,
787            "Excess heading-adjacent blanks flagged with default limits"
788        );
789    }
790
791    // Heading awareness tests
792    // MD012 reads MD022's config to determine heading blank line limits.
793    // When MD022 requires N blank lines around headings, MD012 allows up to N.
794
795    #[test]
796    fn test_heading_aware_blanks_below_with_higher_limit() {
797        // With heading_blanks_below = 2, 2 blanks below heading are allowed
798        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
799        let content = "# Heading\n\n\nParagraph\n";
800        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801        let result = rule.check(&ctx).unwrap();
802        assert!(
803            result.is_empty(),
804            "2 blanks below heading allowed with heading_blanks_below=2"
805        );
806    }
807
808    #[test]
809    fn test_heading_aware_blanks_above_with_higher_limit() {
810        // With heading_blanks_above = 2, 2 blanks above heading are allowed
811        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
812        let content = "Paragraph\n\n\n# Heading\n";
813        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
814        let result = rule.check(&ctx).unwrap();
815        assert!(
816            result.is_empty(),
817            "2 blanks above heading allowed with heading_blanks_above=2"
818        );
819    }
820
821    #[test]
822    fn test_heading_aware_blanks_between_headings() {
823        // Between headings, use the larger of above/below limits
824        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
825        let content = "# Heading 1\n\n\n## Heading 2\n";
826        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
827        let result = rule.check(&ctx).unwrap();
828        assert!(result.is_empty(), "2 blanks between headings allowed with limits=2");
829    }
830
831    #[test]
832    fn test_heading_aware_excess_still_flagged() {
833        // Even with heading limits, excess beyond the limit is flagged
834        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
835        let content = "# Heading\n\n\n\n\nParagraph\n"; // 4 blanks, limit is 2
836        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837        let result = rule.check(&ctx).unwrap();
838        assert_eq!(result.len(), 2, "Excess beyond heading limit should be flagged");
839    }
840
841    #[test]
842    fn test_heading_aware_setext_blanks_below() {
843        // Setext headings with heading limits
844        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
845        let content = "Heading\n=======\n\n\nParagraph\n";
846        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
847        let result = rule.check(&ctx).unwrap();
848        assert!(result.is_empty(), "2 blanks below Setext heading allowed with limit=2");
849    }
850
851    #[test]
852    fn test_heading_aware_setext_blanks_above() {
853        // Setext headings with heading limits
854        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
855        let content = "Paragraph\n\n\nHeading\n=======\n";
856        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857        let result = rule.check(&ctx).unwrap();
858        assert!(result.is_empty(), "2 blanks above Setext heading allowed with limit=2");
859    }
860
861    #[test]
862    fn test_heading_aware_single_blank_allowed() {
863        // 1 blank near heading is always allowed
864        let rule = MD012NoMultipleBlanks::default();
865        let content = "# Heading\n\nParagraph\n";
866        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867        let result = rule.check(&ctx).unwrap();
868        assert!(result.is_empty(), "Single blank near heading should be allowed");
869    }
870
871    #[test]
872    fn test_heading_aware_non_heading_blanks_still_flagged() {
873        // Blanks between non-heading content should still be flagged
874        let rule = MD012NoMultipleBlanks::default();
875        let content = "Paragraph 1\n\n\nParagraph 2\n";
876        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
877        let result = rule.check(&ctx).unwrap();
878        assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
879    }
880
881    #[test]
882    fn test_heading_aware_fix_caps_heading_blanks() {
883        // MD012 fix caps heading-adjacent blanks at effective max
884        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
885        let content = "# Heading\n\n\n\nParagraph\n"; // 3 blanks, limit below is 2
886        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
887        let fixed = rule.fix(&ctx).unwrap();
888        assert_eq!(
889            fixed, "# Heading\n\n\nParagraph\n",
890            "Fix caps heading-adjacent blanks at effective max (2)"
891        );
892    }
893
894    #[test]
895    fn test_heading_aware_fix_preserves_allowed_heading_blanks() {
896        // When blanks are within the heading limit, fix preserves them
897        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 3);
898        let content = "# Heading\n\n\n\nParagraph\n"; // 3 blanks, limit below is 3
899        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900        let fixed = rule.fix(&ctx).unwrap();
901        assert_eq!(
902            fixed, "# Heading\n\n\n\nParagraph\n",
903            "Fix preserves blanks within the heading limit"
904        );
905    }
906
907    #[test]
908    fn test_heading_aware_fix_reduces_non_heading_blanks() {
909        // Fix should still reduce non-heading blanks
910        let rule = MD012NoMultipleBlanks::default();
911        let content = "Paragraph 1\n\n\n\nParagraph 2\n";
912        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
913        let fixed = rule.fix(&ctx).unwrap();
914        assert_eq!(
915            fixed, "Paragraph 1\n\nParagraph 2\n",
916            "Fix should reduce non-heading blanks"
917        );
918    }
919
920    #[test]
921    fn test_heading_aware_mixed_heading_and_non_heading() {
922        // With heading limits, heading-adjacent gaps use higher limit
923        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
924        let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
925        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926        let result = rule.check(&ctx).unwrap();
927        // heading->para gap (2 blanks, limit=2): ok. para->para gap (2 blanks, limit=1): flagged
928        assert_eq!(result.len(), 1, "Only non-heading excess should be flagged");
929    }
930
931    #[test]
932    fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
933        // Start-of-file blanks are always flagged, even before a heading.
934        // No rule requires blanks at the absolute start of a file.
935        let rule = MD012NoMultipleBlanks::default().with_heading_limits(3, 3);
936        let content = "\n\n\n# Heading\n";
937        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938        let result = rule.check(&ctx).unwrap();
939        assert_eq!(
940            result.len(),
941            2,
942            "Start-of-file blanks should be flagged even before heading"
943        );
944        assert!(result[0].message.contains("at start of file"));
945    }
946
947    #[test]
948    fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
949        // EOF blanks should still be flagged even after a heading
950        let rule = MD012NoMultipleBlanks::default();
951        let content = "# Heading\n\n";
952        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
953        let result = rule.check(&ctx).unwrap();
954        assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
955        assert!(result[0].message.contains("at end of file"));
956    }
957
958    #[test]
959    fn test_heading_aware_unlimited_heading_blanks() {
960        // With usize::MAX heading limit (Unlimited in MD022), MD012 never flags heading-adjacent
961        let rule = MD012NoMultipleBlanks::default().with_heading_limits(usize::MAX, usize::MAX);
962        let content = "# Heading\n\n\n\n\nParagraph\n"; // 4 blanks below heading
963        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
964        let result = rule.check(&ctx).unwrap();
965        assert!(
966            result.is_empty(),
967            "Unlimited heading limits means MD012 never flags near headings"
968        );
969    }
970
971    #[test]
972    fn test_heading_aware_blanks_after_code_then_heading() {
973        // Blanks after code block are not heading-adjacent (prev_content_line_num reset)
974        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
975        let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
976        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
977        let result = rule.check(&ctx).unwrap();
978        // The blanks are between code block and "More text" (not heading-adjacent)
979        assert_eq!(result.len(), 2, "Non-heading blanks after code block should be flagged");
980    }
981
982    #[test]
983    fn test_heading_aware_fix_mixed_document() {
984        // MD012 fix with heading limits
985        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
986        let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
987        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988        let fixed = rule.fix(&ctx).unwrap();
989        // Heading-adjacent blanks preserved (within limit=2), non-heading blanks reduced
990        assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
991    }
992
993    #[test]
994    fn test_heading_aware_from_config_reads_md022() {
995        // from_config reads MD022 config to determine heading limits
996        let mut config = crate::config::Config::default();
997        let mut md022_config = crate::config::RuleConfig::default();
998        md022_config
999            .values
1000            .insert("lines-above".to_string(), toml::Value::Integer(2));
1001        md022_config
1002            .values
1003            .insert("lines-below".to_string(), toml::Value::Integer(3));
1004        config.rules.insert("MD022".to_string(), md022_config);
1005
1006        let rule = MD012NoMultipleBlanks::from_config(&config);
1007        // With MD022 lines-above=2: 2 blanks above heading should be allowed
1008        let content = "Paragraph\n\n\n# Heading\n";
1009        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1010        let result = rule.check(&ctx).unwrap();
1011        assert!(
1012            result.is_empty(),
1013            "2 blanks above heading allowed when MD022 lines-above=2"
1014        );
1015    }
1016
1017    #[test]
1018    fn test_heading_aware_from_config_md022_disabled() {
1019        // When MD022 is disabled, MD012 uses its own maximum everywhere
1020        let mut config = crate::config::Config::default();
1021        config.global.disable.push("MD022".to_string());
1022
1023        let mut md022_config = crate::config::RuleConfig::default();
1024        md022_config
1025            .values
1026            .insert("lines-above".to_string(), toml::Value::Integer(3));
1027        config.rules.insert("MD022".to_string(), md022_config);
1028
1029        let rule = MD012NoMultipleBlanks::from_config(&config);
1030        // MD022 disabled: heading-adjacent blanks treated like any other
1031        let content = "Paragraph\n\n\n# Heading\n";
1032        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033        let result = rule.check(&ctx).unwrap();
1034        assert_eq!(
1035            result.len(),
1036            1,
1037            "With MD022 disabled, heading-adjacent blanks are flagged"
1038        );
1039    }
1040
1041    #[test]
1042    fn test_heading_aware_from_config_md022_unlimited() {
1043        // When MD022 has lines-above = -1 (Unlimited), MD012 never flags above headings
1044        let mut config = crate::config::Config::default();
1045        let mut md022_config = crate::config::RuleConfig::default();
1046        md022_config
1047            .values
1048            .insert("lines-above".to_string(), toml::Value::Integer(-1));
1049        config.rules.insert("MD022".to_string(), md022_config);
1050
1051        let rule = MD012NoMultipleBlanks::from_config(&config);
1052        let content = "Paragraph\n\n\n\n\n# Heading\n"; // 4 blanks above heading
1053        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1054        let result = rule.check(&ctx).unwrap();
1055        assert!(
1056            result.is_empty(),
1057            "Unlimited MD022 lines-above means MD012 never flags above headings"
1058        );
1059    }
1060
1061    #[test]
1062    fn test_heading_aware_from_config_per_level() {
1063        // Per-level config: max_heading_limit takes the maximum across all levels.
1064        // lines-above = [2, 1, 1, 1, 1, 1] → heading_blanks_above = 2 (max of all levels).
1065        // This means 2 blanks above ANY heading is allowed, even if only H1 needs 2.
1066        // This is a deliberate trade-off: conservative (no false positives from MD012).
1067        let mut config = crate::config::Config::default();
1068        let mut md022_config = crate::config::RuleConfig::default();
1069        md022_config.values.insert(
1070            "lines-above".to_string(),
1071            toml::Value::Array(vec![
1072                toml::Value::Integer(2),
1073                toml::Value::Integer(1),
1074                toml::Value::Integer(1),
1075                toml::Value::Integer(1),
1076                toml::Value::Integer(1),
1077                toml::Value::Integer(1),
1078            ]),
1079        );
1080        config.rules.insert("MD022".to_string(), md022_config);
1081
1082        let rule = MD012NoMultipleBlanks::from_config(&config);
1083
1084        // 2 blanks above H2: MD012 allows it (max across levels is 2)
1085        let content = "Paragraph\n\n\n## H2 Heading\n";
1086        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1087        let result = rule.check(&ctx).unwrap();
1088        assert!(result.is_empty(), "Per-level max (2) allows 2 blanks above any heading");
1089
1090        // 3 blanks above H2: exceeds the per-level max of 2
1091        let content = "Paragraph\n\n\n\n## H2 Heading\n";
1092        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1093        let result = rule.check(&ctx).unwrap();
1094        assert_eq!(result.len(), 1, "3 blanks exceeds per-level max of 2");
1095    }
1096
1097    #[test]
1098    fn test_issue_449_reproduction() {
1099        // Exact reproduction case from GitHub issue #449.
1100        // With default settings, excess blanks around headings should be flagged.
1101        let rule = MD012NoMultipleBlanks::default();
1102        let content = "\
1103# Heading
1104
1105
1106Some introductory text.
1107
1108
1109
1110
1111
1112## Heading level 2
1113
1114
1115Some text for this section.
1116
1117Some more text for this section.
1118
1119
1120## Another heading level 2
1121
1122
1123
1124Some text for this section.
1125
1126Some more text for this section.
1127";
1128        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1129        let result = rule.check(&ctx).unwrap();
1130        assert!(
1131            !result.is_empty(),
1132            "Issue #449: excess blanks around headings should be flagged with default settings"
1133        );
1134
1135        // Verify fix produces clean output
1136        let fixed = rule.fix(&ctx).unwrap();
1137        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1138        let recheck = rule.check(&fixed_ctx).unwrap();
1139        assert!(recheck.is_empty(), "Fix should resolve all excess blank lines");
1140
1141        // Verify the fixed output has exactly 1 blank line around each heading
1142        assert!(fixed.contains("# Heading\n\nSome"), "1 blank below first heading");
1143        assert!(
1144            fixed.contains("text.\n\n## Heading level 2"),
1145            "1 blank above second heading"
1146        );
1147    }
1148
1149    // Quarto flavor tests
1150
1151    #[test]
1152    fn test_blank_lines_in_quarto_callout() {
1153        // Blank lines inside Quarto callout blocks should be allowed
1154        let rule = MD012NoMultipleBlanks::default();
1155        let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
1156        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1157        let result = rule.check(&ctx).unwrap();
1158        assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
1159    }
1160
1161    #[test]
1162    fn test_blank_lines_in_quarto_div() {
1163        // Blank lines inside generic Quarto divs should be allowed
1164        let rule = MD012NoMultipleBlanks::default();
1165        let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
1166        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1167        let result = rule.check(&ctx).unwrap();
1168        assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
1169    }
1170
1171    #[test]
1172    fn test_blank_lines_outside_quarto_div_flagged() {
1173        // Blank lines outside Quarto divs should still be flagged
1174        let rule = MD012NoMultipleBlanks::default();
1175        let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
1176        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1177        let result = rule.check(&ctx).unwrap();
1178        assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1179    }
1180
1181    #[test]
1182    fn test_quarto_divs_ignored_in_standard_flavor() {
1183        // In standard flavor, Quarto div syntax is not special
1184        let rule = MD012NoMultipleBlanks::default();
1185        let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1186        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187        let result = rule.check(&ctx).unwrap();
1188        // In standard flavor, the triple blank inside "div" is flagged
1189        assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1190    }
1191
1192    // Roundtrip safety tests: fix then re-check = 0 violations
1193
1194    #[test]
1195    fn test_roundtrip_multiple_blank_lines() {
1196        let rule = MD012NoMultipleBlanks::default();
1197        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
1198        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1199        let fixed = rule.fix(&ctx).unwrap();
1200        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1201        let recheck = rule.check(&ctx2).unwrap();
1202        assert!(
1203            recheck.is_empty(),
1204            "Roundtrip: fix then check should be clean, got {recheck:?}"
1205        );
1206    }
1207
1208    #[test]
1209    fn test_roundtrip_trailing_blanks() {
1210        let rule = MD012NoMultipleBlanks::default();
1211        let content = "Content\n\n\n\n";
1212        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1213        let fixed = rule.fix(&ctx).unwrap();
1214        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1215        let recheck = rule.check(&ctx2).unwrap();
1216        assert!(recheck.is_empty(), "Roundtrip: trailing blanks, got {recheck:?}");
1217    }
1218
1219    #[test]
1220    fn test_roundtrip_leading_blanks() {
1221        let rule = MD012NoMultipleBlanks::default();
1222        let content = "\n\n\nContent\n";
1223        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1224        let fixed = rule.fix(&ctx).unwrap();
1225        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1226        let recheck = rule.check(&ctx2).unwrap();
1227        assert!(recheck.is_empty(), "Roundtrip: leading blanks, got {recheck:?}");
1228    }
1229
1230    #[test]
1231    fn test_roundtrip_custom_maximum() {
1232        let rule = MD012NoMultipleBlanks::new(2);
1233        let content = "Line 1\n\n\n\n\nLine 2\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: max=2, got {recheck:?}");
1239    }
1240
1241    #[test]
1242    fn test_roundtrip_code_blocks() {
1243        let rule = MD012NoMultipleBlanks::default();
1244        let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
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: code blocks, got {recheck:?}");
1250    }
1251
1252    #[test]
1253    fn test_roundtrip_heading_limits() {
1254        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1255        let content = "# Heading\n\n\n\n\nParagraph\n\n\n\n## Heading 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: heading limits, got {recheck:?}");
1261    }
1262
1263    #[test]
1264    fn test_roundtrip_front_matter() {
1265        let rule = MD012NoMultipleBlanks::default();
1266        let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\n\n\nContent\n";
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: front matter, got {recheck:?}");
1272    }
1273
1274    #[test]
1275    fn test_roundtrip_only_blanks() {
1276        let rule = MD012NoMultipleBlanks::default();
1277        let content = "\n\n\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: only blanks, got {recheck:?}");
1283    }
1284
1285    #[test]
1286    fn test_roundtrip_single_eof_blank() {
1287        let rule = MD012NoMultipleBlanks::default();
1288        let content = "Content\n\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: single EOF blank, got {recheck:?}");
1294    }
1295
1296    #[test]
1297    fn test_roundtrip_mixed_heading_and_non_heading() {
1298        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
1299        let content = "# Heading\n\n\n\nParagraph 1\n\n\n\nParagraph 2\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!(
1305            recheck.is_empty(),
1306            "Roundtrip: mixed heading/non-heading, got {recheck:?}"
1307        );
1308    }
1309}