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