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