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