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 {
111                        range: {
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                        replacement: 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_quarto_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 {
339                    range: fix_start..fix_end,
340                    // The fix_start already points to the first blank line, which is AFTER
341                    // the last content line's newline. So we just remove everything from
342                    // fix_start to end, and the last content line's newline is preserved.
343                    replacement: String::new(),
344                }),
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_fix_preserves_code_block_blanks() {
478        let rule = MD012NoMultipleBlanks::default();
479        let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
480        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481        let fixed = rule.fix(&ctx).unwrap();
482        assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
483    }
484
485    #[test]
486    fn test_blank_lines_in_front_matter() {
487        let rule = MD012NoMultipleBlanks::default();
488        let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
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 in front matter are ignored
492    }
493
494    #[test]
495    fn test_blank_lines_at_start() {
496        let rule = MD012NoMultipleBlanks::default();
497        let content = "\n\n\nContent";
498        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
499        let result = rule.check(&ctx).unwrap();
500        assert_eq!(result.len(), 2);
501        assert!(result[0].message.contains("at start of file"));
502    }
503
504    #[test]
505    fn test_blank_lines_at_end() {
506        let rule = MD012NoMultipleBlanks::default();
507        let content = "Content\n\n\n";
508        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509        let result = rule.check(&ctx).unwrap();
510        assert_eq!(result.len(), 1);
511        assert!(result[0].message.contains("at end of file"));
512    }
513
514    #[test]
515    fn test_single_blank_at_eof_flagged() {
516        // Markdownlint behavior: ANY blank lines at EOF are flagged
517        let rule = MD012NoMultipleBlanks::default();
518        let content = "Content\n\n";
519        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
520        let result = rule.check(&ctx).unwrap();
521        assert_eq!(result.len(), 1);
522        assert!(result[0].message.contains("at end of file"));
523    }
524
525    #[test]
526    fn test_whitespace_only_lines() {
527        let rule = MD012NoMultipleBlanks::default();
528        let content = "Line 1\n  \n\t\nLine 2";
529        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
530        let result = rule.check(&ctx).unwrap();
531        assert_eq!(result.len(), 1); // Whitespace-only lines count as blank
532    }
533
534    #[test]
535    fn test_indented_code_blocks() {
536        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
537        let rule = MD012NoMultipleBlanks::default();
538        let content = "Text\n\n    code\n    \n    \n    more code\n\nText";
539        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
540        let result = rule.check(&ctx).unwrap();
541        assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
542    }
543
544    #[test]
545    fn test_blanks_in_indented_code_block() {
546        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
547        let content = "    code line 1\n\n\n    code line 2\n";
548        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
549        let rule = MD012NoMultipleBlanks::default();
550        let warnings = rule.check(&ctx).unwrap();
551        assert!(warnings.is_empty(), "Should not flag blanks in indented code");
552    }
553
554    #[test]
555    fn test_blanks_in_indented_code_block_with_heading() {
556        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
557        let content = "# Heading\n\n    code line 1\n\n\n    code line 2\n\nMore text\n";
558        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
559        let rule = MD012NoMultipleBlanks::default();
560        let warnings = rule.check(&ctx).unwrap();
561        assert!(
562            warnings.is_empty(),
563            "Should not flag blanks in indented code after heading"
564        );
565    }
566
567    #[test]
568    fn test_blanks_after_indented_code_block_flagged() {
569        // Blanks AFTER an indented code block end should still be flagged
570        let content = "# Heading\n\n    code line\n\n\n\nMore text\n";
571        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
572        let rule = MD012NoMultipleBlanks::default();
573        let warnings = rule.check(&ctx).unwrap();
574        // There are 3 blank lines after the code block, so 2 extra should be flagged
575        assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
576    }
577
578    #[test]
579    fn test_fix_with_final_newline() {
580        let rule = MD012NoMultipleBlanks::default();
581        let content = "Line 1\n\n\nLine 2\n";
582        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583        let fixed = rule.fix(&ctx).unwrap();
584        assert_eq!(fixed, "Line 1\n\nLine 2\n");
585        assert!(fixed.ends_with('\n'));
586    }
587
588    #[test]
589    fn test_empty_content() {
590        let rule = MD012NoMultipleBlanks::default();
591        let content = "";
592        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593        let result = rule.check(&ctx).unwrap();
594        assert!(result.is_empty());
595    }
596
597    #[test]
598    fn test_nested_code_blocks() {
599        let rule = MD012NoMultipleBlanks::default();
600        let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
601        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
602        let result = rule.check(&ctx).unwrap();
603        assert!(result.is_empty());
604    }
605
606    #[test]
607    fn test_unclosed_code_block() {
608        let rule = MD012NoMultipleBlanks::default();
609        let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
610        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
611        let result = rule.check(&ctx).unwrap();
612        assert!(result.is_empty()); // Unclosed code blocks still preserve blank lines
613    }
614
615    #[test]
616    fn test_mixed_fence_styles() {
617        let rule = MD012NoMultipleBlanks::default();
618        let content = "Before\n\n```\ncode\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()); // Mixed fence styles should work
622    }
623
624    #[test]
625    fn test_config_from_toml() {
626        let mut config = crate::config::Config::default();
627        let mut rule_config = crate::config::RuleConfig::default();
628        rule_config
629            .values
630            .insert("maximum".to_string(), toml::Value::Integer(3));
631        config.rules.insert("MD012".to_string(), rule_config);
632
633        let rule = MD012NoMultipleBlanks::from_config(&config);
634        let content = "Line 1\n\n\n\nLine 2"; // 3 blank lines
635        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636        let result = rule.check(&ctx).unwrap();
637        assert!(result.is_empty()); // 3 blank lines allowed with maximum=3
638    }
639
640    #[test]
641    fn test_blank_lines_between_sections() {
642        // With heading limits from MD022, heading-adjacent excess is allowed up to the limit
643        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
644        let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
645        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646        let result = rule.check(&ctx).unwrap();
647        assert!(
648            result.is_empty(),
649            "2 blanks above heading allowed with heading_blanks_above=2"
650        );
651    }
652
653    #[test]
654    fn test_fix_preserves_indented_code() {
655        let rule = MD012NoMultipleBlanks::default();
656        let content = "Text\n\n\n    code\n    \n    more code\n\n\nText";
657        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
658        let fixed = rule.fix(&ctx).unwrap();
659        // Fix removes excess blank lines outside code blocks but preserves
660        // whitespace-only lines inside indented code blocks unchanged.
661        assert_eq!(fixed, "Text\n\n    code\n    \n    more code\n\nText");
662    }
663
664    #[test]
665    fn test_edge_case_only_blanks() {
666        let rule = MD012NoMultipleBlanks::default();
667        let content = "\n\n\n";
668        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669        let result = rule.check(&ctx).unwrap();
670        // With the new EOF handling, we report once at EOF
671        assert_eq!(result.len(), 1);
672        assert!(result[0].message.contains("at end of file"));
673    }
674
675    // Regression tests for blanks after code blocks (GitHub issue #199 related)
676
677    #[test]
678    fn test_blanks_after_fenced_code_block_mid_document() {
679        // Blanks between code block and heading use heading_above limit
680        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
681        let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
682        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683        let result = rule.check(&ctx).unwrap();
684        assert!(
685            result.is_empty(),
686            "2 blanks before heading allowed with heading_blanks_above=2"
687        );
688    }
689
690    #[test]
691    fn test_blanks_after_code_block_at_eof() {
692        // Trailing blanks after code block at end of file
693        let rule = MD012NoMultipleBlanks::default();
694        let content = "# Heading\n\n```\ncode\n```\n\n\n";
695        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
696        let result = rule.check(&ctx).unwrap();
697        // Should flag the trailing blanks at EOF
698        assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
699        assert!(result[0].message.contains("at end of file"));
700    }
701
702    #[test]
703    fn test_single_blank_after_code_block_allowed() {
704        // Single blank after code block is allowed (default max=1)
705        let rule = MD012NoMultipleBlanks::default();
706        let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
707        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
708        let result = rule.check(&ctx).unwrap();
709        assert!(result.is_empty(), "Single blank after code block should be allowed");
710    }
711
712    #[test]
713    fn test_multiple_code_blocks_with_blanks() {
714        // Multiple code blocks, each followed by blanks
715        let rule = MD012NoMultipleBlanks::default();
716        let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
717        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
718        let result = rule.check(&ctx).unwrap();
719        // Should flag both double-blank sequences
720        assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
721    }
722
723    #[test]
724    fn test_whitespace_only_lines_after_code_block_at_eof() {
725        // Whitespace-only lines (not just empty) after code block at EOF
726        // This matches the React repo pattern where lines have trailing spaces
727        let rule = MD012NoMultipleBlanks::default();
728        let content = "```\ncode\n```\n   \n   \n";
729        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730        let result = rule.check(&ctx).unwrap();
731        assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
732        assert!(result[0].message.contains("at end of file"));
733    }
734
735    // Tests for warning-based fix (used by LSP formatting)
736
737    #[test]
738    fn test_warning_fix_removes_single_trailing_blank() {
739        // Regression test for issue #265: LSP formatting should work for EOF blanks
740        let rule = MD012NoMultipleBlanks::default();
741        let content = "hello foobar hello.\n\n";
742        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
743        let warnings = rule.check(&ctx).unwrap();
744
745        assert_eq!(warnings.len(), 1);
746        assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
747
748        let fix = warnings[0].fix.as_ref().unwrap();
749        // The fix should remove the trailing blank line
750        assert_eq!(fix.replacement, "", "Replacement should be empty");
751
752        // Apply the fix and verify result
753        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
754        assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
755    }
756
757    #[test]
758    fn test_warning_fix_removes_multiple_trailing_blanks() {
759        let rule = MD012NoMultipleBlanks::default();
760        let content = "content\n\n\n\n";
761        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762        let warnings = rule.check(&ctx).unwrap();
763
764        assert_eq!(warnings.len(), 1);
765        assert!(warnings[0].fix.is_some());
766
767        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
768        assert_eq!(fixed, "content\n", "Should end with single newline");
769    }
770
771    #[test]
772    fn test_warning_fix_preserves_content_newline() {
773        // Ensure the fix doesn't remove the content line's trailing newline
774        let rule = MD012NoMultipleBlanks::default();
775        let content = "line1\nline2\n\n";
776        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777        let warnings = rule.check(&ctx).unwrap();
778
779        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
780        assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
781    }
782
783    #[test]
784    fn test_warning_fix_mid_document_blanks() {
785        // With default limits (1,1), heading-adjacent excess blanks are flagged
786        let rule = MD012NoMultipleBlanks::default();
787        let content = "# Heading\n\n\n\nParagraph\n";
788        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
789        let warnings = rule.check(&ctx).unwrap();
790        assert_eq!(
791            warnings.len(),
792            2,
793            "Excess heading-adjacent blanks flagged with default limits"
794        );
795    }
796
797    // Heading awareness tests
798    // MD012 reads MD022's config to determine heading blank line limits.
799    // When MD022 requires N blank lines around headings, MD012 allows up to N.
800
801    #[test]
802    fn test_heading_aware_blanks_below_with_higher_limit() {
803        // With heading_blanks_below = 2, 2 blanks below heading are allowed
804        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
805        let content = "# Heading\n\n\nParagraph\n";
806        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
807        let result = rule.check(&ctx).unwrap();
808        assert!(
809            result.is_empty(),
810            "2 blanks below heading allowed with heading_blanks_below=2"
811        );
812    }
813
814    #[test]
815    fn test_heading_aware_blanks_above_with_higher_limit() {
816        // With heading_blanks_above = 2, 2 blanks above heading are allowed
817        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
818        let content = "Paragraph\n\n\n# Heading\n";
819        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
820        let result = rule.check(&ctx).unwrap();
821        assert!(
822            result.is_empty(),
823            "2 blanks above heading allowed with heading_blanks_above=2"
824        );
825    }
826
827    #[test]
828    fn test_heading_aware_blanks_between_headings() {
829        // Between headings, use the larger of above/below limits
830        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
831        let content = "# Heading 1\n\n\n## Heading 2\n";
832        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833        let result = rule.check(&ctx).unwrap();
834        assert!(result.is_empty(), "2 blanks between headings allowed with limits=2");
835    }
836
837    #[test]
838    fn test_heading_aware_excess_still_flagged() {
839        // Even with heading limits, excess beyond the limit is flagged
840        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
841        let content = "# Heading\n\n\n\n\nParagraph\n"; // 4 blanks, limit is 2
842        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
843        let result = rule.check(&ctx).unwrap();
844        assert_eq!(result.len(), 2, "Excess beyond heading limit should be flagged");
845    }
846
847    #[test]
848    fn test_heading_aware_setext_blanks_below() {
849        // Setext headings with heading limits
850        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
851        let content = "Heading\n=======\n\n\nParagraph\n";
852        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853        let result = rule.check(&ctx).unwrap();
854        assert!(result.is_empty(), "2 blanks below Setext heading allowed with limit=2");
855    }
856
857    #[test]
858    fn test_heading_aware_setext_blanks_above() {
859        // Setext headings with heading limits
860        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
861        let content = "Paragraph\n\n\nHeading\n=======\n";
862        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
863        let result = rule.check(&ctx).unwrap();
864        assert!(result.is_empty(), "2 blanks above Setext heading allowed with limit=2");
865    }
866
867    #[test]
868    fn test_heading_aware_single_blank_allowed() {
869        // 1 blank near heading is always allowed
870        let rule = MD012NoMultipleBlanks::default();
871        let content = "# Heading\n\nParagraph\n";
872        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
873        let result = rule.check(&ctx).unwrap();
874        assert!(result.is_empty(), "Single blank near heading should be allowed");
875    }
876
877    #[test]
878    fn test_heading_aware_non_heading_blanks_still_flagged() {
879        // Blanks between non-heading content should still be flagged
880        let rule = MD012NoMultipleBlanks::default();
881        let content = "Paragraph 1\n\n\nParagraph 2\n";
882        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
883        let result = rule.check(&ctx).unwrap();
884        assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
885    }
886
887    #[test]
888    fn test_heading_aware_fix_caps_heading_blanks() {
889        // MD012 fix caps heading-adjacent blanks at effective max
890        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
891        let content = "# Heading\n\n\n\nParagraph\n"; // 3 blanks, limit below is 2
892        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893        let fixed = rule.fix(&ctx).unwrap();
894        assert_eq!(
895            fixed, "# Heading\n\n\nParagraph\n",
896            "Fix caps heading-adjacent blanks at effective max (2)"
897        );
898    }
899
900    #[test]
901    fn test_heading_aware_fix_preserves_allowed_heading_blanks() {
902        // When blanks are within the heading limit, fix preserves them
903        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 3);
904        let content = "# Heading\n\n\n\nParagraph\n"; // 3 blanks, limit below is 3
905        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
906        let fixed = rule.fix(&ctx).unwrap();
907        assert_eq!(
908            fixed, "# Heading\n\n\n\nParagraph\n",
909            "Fix preserves blanks within the heading limit"
910        );
911    }
912
913    #[test]
914    fn test_heading_aware_fix_reduces_non_heading_blanks() {
915        // Fix should still reduce non-heading blanks
916        let rule = MD012NoMultipleBlanks::default();
917        let content = "Paragraph 1\n\n\n\nParagraph 2\n";
918        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919        let fixed = rule.fix(&ctx).unwrap();
920        assert_eq!(
921            fixed, "Paragraph 1\n\nParagraph 2\n",
922            "Fix should reduce non-heading blanks"
923        );
924    }
925
926    #[test]
927    fn test_heading_aware_mixed_heading_and_non_heading() {
928        // With heading limits, heading-adjacent gaps use higher limit
929        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
930        let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
931        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
932        let result = rule.check(&ctx).unwrap();
933        // heading->para gap (2 blanks, limit=2): ok. para->para gap (2 blanks, limit=1): flagged
934        assert_eq!(result.len(), 1, "Only non-heading excess should be flagged");
935    }
936
937    #[test]
938    fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
939        // Start-of-file blanks are always flagged, even before a heading.
940        // No rule requires blanks at the absolute start of a file.
941        let rule = MD012NoMultipleBlanks::default().with_heading_limits(3, 3);
942        let content = "\n\n\n# Heading\n";
943        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
944        let result = rule.check(&ctx).unwrap();
945        assert_eq!(
946            result.len(),
947            2,
948            "Start-of-file blanks should be flagged even before heading"
949        );
950        assert!(result[0].message.contains("at start of file"));
951    }
952
953    #[test]
954    fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
955        // EOF blanks should still be flagged even after a heading
956        let rule = MD012NoMultipleBlanks::default();
957        let content = "# Heading\n\n";
958        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
959        let result = rule.check(&ctx).unwrap();
960        assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
961        assert!(result[0].message.contains("at end of file"));
962    }
963
964    #[test]
965    fn test_heading_aware_unlimited_heading_blanks() {
966        // With usize::MAX heading limit (Unlimited in MD022), MD012 never flags heading-adjacent
967        let rule = MD012NoMultipleBlanks::default().with_heading_limits(usize::MAX, usize::MAX);
968        let content = "# Heading\n\n\n\n\nParagraph\n"; // 4 blanks below heading
969        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970        let result = rule.check(&ctx).unwrap();
971        assert!(
972            result.is_empty(),
973            "Unlimited heading limits means MD012 never flags near headings"
974        );
975    }
976
977    #[test]
978    fn test_heading_aware_blanks_after_code_then_heading() {
979        // Blanks after code block are not heading-adjacent (prev_content_line_num reset)
980        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
981        let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
982        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
983        let result = rule.check(&ctx).unwrap();
984        // The blanks are between code block and "More text" (not heading-adjacent)
985        assert_eq!(result.len(), 2, "Non-heading blanks after code block should be flagged");
986    }
987
988    #[test]
989    fn test_heading_aware_fix_mixed_document() {
990        // MD012 fix with heading limits
991        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
992        let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
993        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
994        let fixed = rule.fix(&ctx).unwrap();
995        // Heading-adjacent blanks preserved (within limit=2), non-heading blanks reduced
996        assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
997    }
998
999    #[test]
1000    fn test_heading_aware_from_config_reads_md022() {
1001        // from_config reads MD022 config to determine heading limits
1002        let mut config = crate::config::Config::default();
1003        let mut md022_config = crate::config::RuleConfig::default();
1004        md022_config
1005            .values
1006            .insert("lines-above".to_string(), toml::Value::Integer(2));
1007        md022_config
1008            .values
1009            .insert("lines-below".to_string(), toml::Value::Integer(3));
1010        config.rules.insert("MD022".to_string(), md022_config);
1011
1012        let rule = MD012NoMultipleBlanks::from_config(&config);
1013        // With MD022 lines-above=2: 2 blanks above heading should be allowed
1014        let content = "Paragraph\n\n\n# Heading\n";
1015        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1016        let result = rule.check(&ctx).unwrap();
1017        assert!(
1018            result.is_empty(),
1019            "2 blanks above heading allowed when MD022 lines-above=2"
1020        );
1021    }
1022
1023    #[test]
1024    fn test_heading_aware_from_config_md022_disabled() {
1025        // When MD022 is disabled, MD012 uses its own maximum everywhere
1026        let mut config = crate::config::Config::default();
1027        config.global.disable.push("MD022".to_string());
1028
1029        let mut md022_config = crate::config::RuleConfig::default();
1030        md022_config
1031            .values
1032            .insert("lines-above".to_string(), toml::Value::Integer(3));
1033        config.rules.insert("MD022".to_string(), md022_config);
1034
1035        let rule = MD012NoMultipleBlanks::from_config(&config);
1036        // MD022 disabled: heading-adjacent blanks treated like any other
1037        let content = "Paragraph\n\n\n# Heading\n";
1038        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039        let result = rule.check(&ctx).unwrap();
1040        assert_eq!(
1041            result.len(),
1042            1,
1043            "With MD022 disabled, heading-adjacent blanks are flagged"
1044        );
1045    }
1046
1047    #[test]
1048    fn test_heading_aware_from_config_md022_unlimited() {
1049        // When MD022 has lines-above = -1 (Unlimited), MD012 never flags above headings
1050        let mut config = crate::config::Config::default();
1051        let mut md022_config = crate::config::RuleConfig::default();
1052        md022_config
1053            .values
1054            .insert("lines-above".to_string(), toml::Value::Integer(-1));
1055        config.rules.insert("MD022".to_string(), md022_config);
1056
1057        let rule = MD012NoMultipleBlanks::from_config(&config);
1058        let content = "Paragraph\n\n\n\n\n# Heading\n"; // 4 blanks above heading
1059        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1060        let result = rule.check(&ctx).unwrap();
1061        assert!(
1062            result.is_empty(),
1063            "Unlimited MD022 lines-above means MD012 never flags above headings"
1064        );
1065    }
1066
1067    #[test]
1068    fn test_heading_aware_from_config_per_level() {
1069        // Per-level config: max_heading_limit takes the maximum across all levels.
1070        // lines-above = [2, 1, 1, 1, 1, 1] → heading_blanks_above = 2 (max of all levels).
1071        // This means 2 blanks above ANY heading is allowed, even if only H1 needs 2.
1072        // This is a deliberate trade-off: conservative (no false positives from MD012).
1073        let mut config = crate::config::Config::default();
1074        let mut md022_config = crate::config::RuleConfig::default();
1075        md022_config.values.insert(
1076            "lines-above".to_string(),
1077            toml::Value::Array(vec![
1078                toml::Value::Integer(2),
1079                toml::Value::Integer(1),
1080                toml::Value::Integer(1),
1081                toml::Value::Integer(1),
1082                toml::Value::Integer(1),
1083                toml::Value::Integer(1),
1084            ]),
1085        );
1086        config.rules.insert("MD022".to_string(), md022_config);
1087
1088        let rule = MD012NoMultipleBlanks::from_config(&config);
1089
1090        // 2 blanks above H2: MD012 allows it (max across levels is 2)
1091        let content = "Paragraph\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!(result.is_empty(), "Per-level max (2) allows 2 blanks above any heading");
1095
1096        // 3 blanks above H2: exceeds the per-level max of 2
1097        let content = "Paragraph\n\n\n\n## H2 Heading\n";
1098        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1099        let result = rule.check(&ctx).unwrap();
1100        assert_eq!(result.len(), 1, "3 blanks exceeds per-level max of 2");
1101    }
1102
1103    #[test]
1104    fn test_issue_449_reproduction() {
1105        // Exact reproduction case from GitHub issue #449.
1106        // With default settings, excess blanks around headings should be flagged.
1107        let rule = MD012NoMultipleBlanks::default();
1108        let content = "\
1109# Heading
1110
1111
1112Some introductory text.
1113
1114
1115
1116
1117
1118## Heading level 2
1119
1120
1121Some text for this section.
1122
1123Some more text for this section.
1124
1125
1126## Another heading level 2
1127
1128
1129
1130Some text for this section.
1131
1132Some more text for this section.
1133";
1134        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1135        let result = rule.check(&ctx).unwrap();
1136        assert!(
1137            !result.is_empty(),
1138            "Issue #449: excess blanks around headings should be flagged with default settings"
1139        );
1140
1141        // Verify fix produces clean output
1142        let fixed = rule.fix(&ctx).unwrap();
1143        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1144        let recheck = rule.check(&fixed_ctx).unwrap();
1145        assert!(recheck.is_empty(), "Fix should resolve all excess blank lines");
1146
1147        // Verify the fixed output has exactly 1 blank line around each heading
1148        assert!(fixed.contains("# Heading\n\nSome"), "1 blank below first heading");
1149        assert!(
1150            fixed.contains("text.\n\n## Heading level 2"),
1151            "1 blank above second heading"
1152        );
1153    }
1154
1155    // Quarto flavor tests
1156
1157    #[test]
1158    fn test_blank_lines_in_quarto_callout() {
1159        // Blank lines inside Quarto callout blocks should be allowed
1160        let rule = MD012NoMultipleBlanks::default();
1161        let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
1162        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1163        let result = rule.check(&ctx).unwrap();
1164        assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
1165    }
1166
1167    #[test]
1168    fn test_blank_lines_in_quarto_div() {
1169        // Blank lines inside generic Quarto divs should be allowed
1170        let rule = MD012NoMultipleBlanks::default();
1171        let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
1172        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1173        let result = rule.check(&ctx).unwrap();
1174        assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
1175    }
1176
1177    #[test]
1178    fn test_blank_lines_outside_quarto_div_flagged() {
1179        // Blank lines outside Quarto divs should still be flagged
1180        let rule = MD012NoMultipleBlanks::default();
1181        let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
1182        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1183        let result = rule.check(&ctx).unwrap();
1184        assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1185    }
1186
1187    #[test]
1188    fn test_quarto_divs_ignored_in_standard_flavor() {
1189        // In standard flavor, Quarto div syntax is not special
1190        let rule = MD012NoMultipleBlanks::default();
1191        let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1192        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193        let result = rule.check(&ctx).unwrap();
1194        // In standard flavor, the triple blank inside "div" is flagged
1195        assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1196    }
1197
1198    // Roundtrip safety tests: fix then re-check = 0 violations
1199
1200    #[test]
1201    fn test_roundtrip_multiple_blank_lines() {
1202        let rule = MD012NoMultipleBlanks::default();
1203        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
1204        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1205        let fixed = rule.fix(&ctx).unwrap();
1206        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1207        let recheck = rule.check(&ctx2).unwrap();
1208        assert!(
1209            recheck.is_empty(),
1210            "Roundtrip: fix then check should be clean, got {recheck:?}"
1211        );
1212    }
1213
1214    #[test]
1215    fn test_roundtrip_trailing_blanks() {
1216        let rule = MD012NoMultipleBlanks::default();
1217        let content = "Content\n\n\n\n";
1218        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1219        let fixed = rule.fix(&ctx).unwrap();
1220        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1221        let recheck = rule.check(&ctx2).unwrap();
1222        assert!(recheck.is_empty(), "Roundtrip: trailing blanks, got {recheck:?}");
1223    }
1224
1225    #[test]
1226    fn test_roundtrip_leading_blanks() {
1227        let rule = MD012NoMultipleBlanks::default();
1228        let content = "\n\n\nContent\n";
1229        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1230        let fixed = rule.fix(&ctx).unwrap();
1231        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1232        let recheck = rule.check(&ctx2).unwrap();
1233        assert!(recheck.is_empty(), "Roundtrip: leading blanks, got {recheck:?}");
1234    }
1235
1236    #[test]
1237    fn test_roundtrip_custom_maximum() {
1238        let rule = MD012NoMultipleBlanks::new(2);
1239        let content = "Line 1\n\n\n\n\nLine 2\n";
1240        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1241        let fixed = rule.fix(&ctx).unwrap();
1242        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1243        let recheck = rule.check(&ctx2).unwrap();
1244        assert!(recheck.is_empty(), "Roundtrip: max=2, got {recheck:?}");
1245    }
1246
1247    #[test]
1248    fn test_roundtrip_code_blocks() {
1249        let rule = MD012NoMultipleBlanks::default();
1250        let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
1251        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1252        let fixed = rule.fix(&ctx).unwrap();
1253        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1254        let recheck = rule.check(&ctx2).unwrap();
1255        assert!(recheck.is_empty(), "Roundtrip: code blocks, got {recheck:?}");
1256    }
1257
1258    #[test]
1259    fn test_roundtrip_heading_limits() {
1260        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1261        let content = "# Heading\n\n\n\n\nParagraph\n\n\n\n## Heading 2\n";
1262        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1263        let fixed = rule.fix(&ctx).unwrap();
1264        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1265        let recheck = rule.check(&ctx2).unwrap();
1266        assert!(recheck.is_empty(), "Roundtrip: heading limits, got {recheck:?}");
1267    }
1268
1269    #[test]
1270    fn test_roundtrip_front_matter() {
1271        let rule = MD012NoMultipleBlanks::default();
1272        let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\n\n\nContent\n";
1273        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1274        let fixed = rule.fix(&ctx).unwrap();
1275        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1276        let recheck = rule.check(&ctx2).unwrap();
1277        assert!(recheck.is_empty(), "Roundtrip: front matter, got {recheck:?}");
1278    }
1279
1280    #[test]
1281    fn test_roundtrip_only_blanks() {
1282        let rule = MD012NoMultipleBlanks::default();
1283        let content = "\n\n\n";
1284        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1285        let fixed = rule.fix(&ctx).unwrap();
1286        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1287        let recheck = rule.check(&ctx2).unwrap();
1288        assert!(recheck.is_empty(), "Roundtrip: only blanks, got {recheck:?}");
1289    }
1290
1291    #[test]
1292    fn test_roundtrip_single_eof_blank() {
1293        let rule = MD012NoMultipleBlanks::default();
1294        let content = "Content\n\n";
1295        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1296        let fixed = rule.fix(&ctx).unwrap();
1297        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1298        let recheck = rule.check(&ctx2).unwrap();
1299        assert!(recheck.is_empty(), "Roundtrip: single EOF blank, got {recheck:?}");
1300    }
1301
1302    #[test]
1303    fn test_roundtrip_mixed_heading_and_non_heading() {
1304        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
1305        let content = "# Heading\n\n\n\nParagraph 1\n\n\n\nParagraph 2\n";
1306        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1307        let fixed = rule.fix(&ctx).unwrap();
1308        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1309        let recheck = rule.check(&ctx2).unwrap();
1310        assert!(
1311            recheck.is_empty(),
1312            "Roundtrip: mixed heading/non-heading, got {recheck:?}"
1313        );
1314    }
1315}