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;
4
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6
7mod md012_config;
8use md012_config::MD012Config;
9
10/// Whether `line` is blank. CommonMark blank lines hold only spaces and tabs (and a
11/// stray carriage return); a line holding U+00A0 or another Unicode space is a
12/// paragraph line, so collapsing it would delete text and split the paragraph.
13fn is_blank_line(line: &str) -> bool {
14    line.trim_matches([' ', '\t', '\r']).is_empty()
15}
16
17/// Rule MD012: No multiple consecutive blank lines
18///
19/// See [docs/md012.md](../../docs/md012.md) for full documentation, configuration, and examples.
20#[derive(Debug, Clone)]
21pub struct MD012NoMultipleBlanks {
22    config: MD012Config,
23    /// Maximum blank lines allowed adjacent to headings (above).
24    /// Derived from MD022's lines-above config to avoid conflicts.
25    heading_blanks_above: usize,
26    /// Maximum blank lines allowed adjacent to headings (below).
27    /// Derived from MD022's lines-below config to avoid conflicts.
28    heading_blanks_below: usize,
29}
30
31impl Default for MD012NoMultipleBlanks {
32    fn default() -> Self {
33        Self {
34            config: MD012Config::default(),
35            heading_blanks_above: 1,
36            heading_blanks_below: 1,
37        }
38    }
39}
40
41impl MD012NoMultipleBlanks {
42    pub fn new(maximum: usize) -> Self {
43        use crate::types::PositiveUsize;
44        Self {
45            config: MD012Config {
46                maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
47            },
48            heading_blanks_above: 1,
49            heading_blanks_below: 1,
50        }
51    }
52
53    pub const fn from_config_struct(config: MD012Config) -> Self {
54        Self {
55            config,
56            heading_blanks_above: 1,
57            heading_blanks_below: 1,
58        }
59    }
60
61    /// Set heading blank line limits derived from MD022 config.
62    /// `above` and `below` are the maximum blank lines MD022 allows above/below headings.
63    pub fn with_heading_limits(mut self, above: usize, below: usize) -> Self {
64        self.heading_blanks_above = above;
65        self.heading_blanks_below = below;
66        self
67    }
68
69    /// The effective maximum blank lines allowed for heading-adjacent runs.
70    /// Returns the larger of MD012's own maximum and the relevant MD022 limit,
71    /// so MD012 never flags blanks that MD022 requires.
72    fn effective_max_above(&self) -> usize {
73        self.config.maximum.get().max(self.heading_blanks_above)
74    }
75
76    fn effective_max_below(&self) -> usize {
77        self.config.maximum.get().max(self.heading_blanks_below)
78    }
79
80    /// Build the single warning for one run of consecutive blank lines that
81    /// exceeds `effective_max`.
82    ///
83    /// `blank_start` is the 0-based index of the run's first blank line. The
84    /// warning covers exactly the excess lines, as the half-open range from the
85    /// first excess line's start to the start of the line after the last one,
86    /// which is also the byte range its fix deletes. Editors therefore highlight
87    /// the lines the fix removes, line terminators included.
88    fn excess_run_warning(
89        &self,
90        blank_start: usize,
91        blank_count: usize,
92        effective_max: usize,
93        ctx: &LintContext,
94    ) -> LintWarning {
95        let location = if blank_start == 0 {
96            "at start of file"
97        } else {
98            "between content"
99        };
100
101        // 1-based line numbers of the first and last excess line.
102        let first_excess = blank_start + effective_max + 1;
103        let last_excess = blank_start + blank_count;
104
105        let fix_start = ctx.line_start_byte(first_excess).unwrap_or(0);
106        let fix_end = ctx.line_start_byte(last_excess + 1).unwrap_or(ctx.content.len());
107
108        LintWarning {
109            rule_name: Some(self.name().to_string()),
110            severity: Severity::Warning,
111            message: format!(
112                "Multiple consecutive blank lines {location} ({blank_count} found, {effective_max} allowed)"
113            ),
114            line: first_excess,
115            column: 1,
116            end_line: last_excess + 1,
117            end_column: 1,
118            fix: Some(Fix::new(fix_start..fix_end, String::new())),
119        }
120    }
121}
122
123/// Check if the given 0-based line index is part of a heading.
124///
125/// Returns true if:
126/// - The line has heading info, or is one of the text lines of a setext heading
127///   whose paragraph spans several lines, OR
128/// - The previous line is a Setext heading text line (covers the Setext underline)
129fn is_heading_context(ctx: &LintContext, line_idx: usize) -> bool {
130    if ctx
131        .lines
132        .get(line_idx)
133        .is_some_and(|li| li.heading.is_some() || li.is_setext_heading_text)
134    {
135        return true;
136    }
137    // Check if previous line is a Setext heading text line, 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 native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
166        (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(12))
167    }
168
169    fn name(&self) -> &'static str {
170        "MD012"
171    }
172
173    fn description(&self) -> &'static str {
174        "Multiple consecutive blank lines"
175    }
176
177    fn category(&self) -> RuleCategory {
178        RuleCategory::Whitespace
179    }
180
181    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
182        let content = ctx.content;
183
184        // Early return for empty content
185        if content.is_empty() {
186            return Ok(Vec::new());
187        }
188
189        // Quick check for consecutive newlines or potential whitespace-only lines before processing
190        // Look for multiple consecutive lines that could be blank (empty or whitespace-only)
191        let lines = ctx.raw_lines();
192        let has_potential_blanks = lines
193            .windows(2)
194            .any(|pair| is_blank_line(pair[0]) && is_blank_line(pair[1]));
195
196        // Also check for blanks at EOF (markdownlint behavior). Only the CLI
197        // normalises to LF; a CRLF document's trailing blanks are `\r\n\r\n`,
198        // and the window check above already catches those.
199        let ends_with_multiple_newlines = content.ends_with("\n\n");
200
201        if !has_potential_blanks && !ends_with_multiple_newlines {
202            return Ok(Vec::new());
203        }
204
205        let mut warnings = Vec::new();
206
207        // Single-pass algorithm with immediate counter reset
208        let mut blank_count = 0;
209        let mut blank_start = 0;
210        let mut last_line_num: Option<usize> = None;
211        // Track the last non-blank content line for heading adjacency checks
212        let mut prev_content_line_num: Option<usize> = None;
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            let info = &ctx.lines[line_num];
238            // Blank lines inside template code can be literal value data.
239            // The existing gap handling resets the run after a skipped line.
240            if is_blank_line(line) && ctx.is_inside_template_code(info.byte_offset + info.indent) {
241                continue;
242            }
243
244            // Detect when lines were skipped (e.g., code block content)
245            // If we jump more than 1 line, there was content between, which breaks blank sequences
246            if let Some(last) = last_line_num
247                && line_num > last + 1
248            {
249                // Lines were skipped (code block or similar)
250                // Generate warnings for any accumulated blanks before the skip
251                let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
252                    self.effective_max_below()
253                } else {
254                    self.config.maximum.get()
255                };
256                if blank_count > effective_max {
257                    warnings.push(self.excess_run_warning(blank_start, blank_count, effective_max, ctx));
258                }
259                blank_count = 0;
260                // Reset heading context across skipped regions (code blocks, etc.)
261                prev_content_line_num = None;
262            }
263            last_line_num = Some(line_num);
264
265            if is_blank_line(line) {
266                if blank_count == 0 {
267                    blank_start = line_num;
268                }
269                blank_count += 1;
270            } else {
271                // Determine effective maximum for this blank run.
272                // Heading-adjacent blanks use the higher of MD012's maximum
273                // and MD022's required blank lines, so MD012 doesn't conflict.
274                // Start-of-file blanks (blank_start == 0) before a heading use
275                // the normal maximum — no rule requires blanks at file start.
276                let heading_below = prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx));
277                let heading_above = blank_start > 0 && is_heading_context(ctx, line_num);
278                let effective_max = if heading_below && heading_above {
279                    // Between two headings: use the larger of above/below limits
280                    self.effective_max_above().max(self.effective_max_below())
281                } else if heading_below {
282                    self.effective_max_below()
283                } else if heading_above {
284                    self.effective_max_above()
285                } else {
286                    self.config.maximum.get()
287                };
288
289                if blank_count > effective_max {
290                    warnings.push(self.excess_run_warning(blank_start, blank_count, effective_max, ctx));
291                }
292                blank_count = 0;
293                prev_content_line_num = Some(line_num);
294            }
295        }
296
297        // Handle trailing blanks at EOF
298        // Main loop only reports mid-document blanks (between content)
299        // EOF handler reports trailing blanks with stricter rules (any blank at EOF is flagged)
300        //
301        // The blank_count at end of loop might include blanks BEFORE a code block at EOF,
302        // which aren't truly "trailing blanks". We need to verify the actual last line is blank.
303        let last_line_is_blank = lines.last().is_some_and(|l| is_blank_line(l));
304
305        // Blanks left over before a skipped region that runs to EOF are mid-document
306        // blanks, so they use the same limits as the skip handling inside the loop.
307        if blank_count > 0 && !last_line_is_blank {
308            let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
309                self.effective_max_below()
310            } else {
311                self.config.maximum.get()
312            };
313            if blank_count > effective_max {
314                warnings.push(self.excess_run_warning(blank_start, blank_count, effective_max, ctx));
315            }
316        }
317
318        // Check for trailing blank lines
319        // EOF semantics: ANY blank line at EOF should be flagged (stricter than mid-document)
320        // Only fire if the actual last line(s) of the file are blank
321        if blank_count > 0 && last_line_is_blank {
322            // 1-based line number of the first trailing blank line.
323            let first_blank = lines.len() - blank_count + 1;
324            let fix_start = ctx.line_start_byte(first_blank).unwrap_or(0);
325            let fix_end = content.len();
326
327            // The range ends where the fix does, at end of file. After a final
328            // line terminator that is the start of the (empty) next line;
329            // otherwise it is just past the last character of the final line.
330            let (end_line, end_column) = if content.ends_with('\n') {
331                (lines.len() + 1, 1)
332            } else {
333                let last = lines.last().copied().unwrap_or("");
334                (lines.len(), last.chars().count() + 1)
335            };
336
337            let message = if blank_count == 1 {
338                "Blank line at end of file (1 found, 0 allowed)".to_string()
339            } else {
340                format!("Blank lines at end of file ({blank_count} found, 0 allowed)")
341            };
342
343            warnings.push(LintWarning {
344                rule_name: Some(self.name().to_string()),
345                severity: Severity::Warning,
346                message,
347                line: first_blank,
348                column: 1,
349                end_line,
350                end_column,
351                fix: Some(Fix::new(fix_start..fix_end, String::new())),
352            });
353        }
354
355        Ok(warnings)
356    }
357
358    fn fixes_from_diagnostics(&self) -> bool {
359        true
360    }
361
362    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
363        if self.should_skip(ctx) {
364            return Ok(ctx.content.to_string());
365        }
366        let warnings = self.check(ctx)?;
367        if warnings.is_empty() {
368            return Ok(ctx.content.to_string());
369        }
370        let warnings =
371            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
372        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
373            .map_err(crate::rule::LintError::InvalidInput)
374    }
375
376    fn as_any(&self) -> &dyn std::any::Any {
377        self
378    }
379
380    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
381        // Skip if content is empty or doesn't have newlines (single line can't have multiple blanks)
382        ctx.content.is_empty() || !ctx.has_char('\n')
383    }
384
385    crate::impl_rule_config_sections!(MD012Config);
386
387    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
388    where
389        Self: Sized,
390    {
391        use crate::rules::md022_blanks_around_headings::md022_config::MD022Config;
392
393        let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
394
395        // Read MD022 config to determine heading blank line limits.
396        // If MD022 is disabled, don't apply special heading limits.
397        let md022_disabled = config.global.disable.iter().any(|r| r == "MD022")
398            || config.global.extend_disable.iter().any(|r| r == "MD022");
399
400        let (heading_above, heading_below) = if md022_disabled {
401            // MD022 disabled: no special heading treatment, use MD012's own maximum
402            (rule_config.maximum.get(), rule_config.maximum.get())
403        } else {
404            let md022_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
405            let nested_limit = md022_config
406                .nested_heading_lines_above
407                .as_ref()
408                .map_or(0, max_heading_limit);
409            (
410                max_heading_limit(&md022_config.lines_above).max(nested_limit),
411                max_heading_limit(&md022_config.lines_below).max(nested_limit),
412            )
413        };
414
415        Box::new(Self {
416            config: rule_config,
417            heading_blanks_above: heading_above,
418            heading_blanks_below: heading_below,
419        })
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use crate::lint_context::LintContext;
427
428    #[test]
429    fn test_single_blank_line_allowed() {
430        let rule = MD012NoMultipleBlanks::default();
431        let content = "Line 1\n\nLine 2\n\nLine 3";
432        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
433        let result = rule.check(&ctx).unwrap();
434        assert!(result.is_empty());
435    }
436
437    #[test]
438    fn test_multiple_blank_lines_flagged() {
439        let rule = MD012NoMultipleBlanks::default();
440        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
441        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
442        let result = rule.check(&ctx).unwrap();
443        // One warning per run: the first gap has 1 excess line, the second 2.
444        let spans: Vec<_> = result
445            .iter()
446            .map(|w| (w.line, w.column, w.end_line, w.end_column))
447            .collect();
448        assert_eq!(spans, [(3, 1, 4, 1), (6, 1, 8, 1)]);
449    }
450
451    #[test]
452    fn test_custom_maximum() {
453        let rule = MD012NoMultipleBlanks::new(2);
454        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
455        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456        let result = rule.check(&ctx).unwrap();
457        assert_eq!(result.len(), 1); // Only the fourth blank line is excessive
458        assert_eq!((result[0].line, result[0].end_line), (7, 8));
459    }
460
461    #[test]
462    fn test_fix_multiple_blank_lines() {
463        let rule = MD012NoMultipleBlanks::default();
464        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
465        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
466        let fixed = rule.fix(&ctx).unwrap();
467        assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
468    }
469
470    #[test]
471    fn test_blank_lines_in_code_block() {
472        let rule = MD012NoMultipleBlanks::default();
473        let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
474        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
475        let result = rule.check(&ctx).unwrap();
476        assert!(result.is_empty()); // Blank lines inside code blocks are ignored
477    }
478
479    #[test]
480    fn test_blank_lines_in_html_comment() {
481        let rule = MD012NoMultipleBlanks::default();
482        let content = "Before\n\n<!--\ncomment\n\n\n\nmore comment\n-->\n\nAfter";
483        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
484        let result = rule.check(&ctx).unwrap();
485        assert!(result.is_empty()); // Blank lines inside HTML comments are ignored
486    }
487
488    #[test]
489    fn test_blank_lines_in_html_block() {
490        let rule = MD012NoMultipleBlanks::default();
491        let content = "Before\n\n<script>\n\n\n\n</script>\n\nAfter";
492        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
493        let result = rule.check(&ctx).unwrap();
494        assert!(result.is_empty()); // Blank lines inside HTML blocks are ignored
495    }
496
497    #[test]
498    fn test_fix_preserves_code_block_blanks() {
499        let rule = MD012NoMultipleBlanks::default();
500        let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
501        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
502        let fixed = rule.fix(&ctx).unwrap();
503        assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
504    }
505
506    #[test]
507    fn test_blank_lines_in_front_matter() {
508        let rule = MD012NoMultipleBlanks::default();
509        let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
510        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
511        let result = rule.check(&ctx).unwrap();
512        assert!(result.is_empty()); // Blank lines in front matter are ignored
513    }
514
515    #[test]
516    fn test_blank_lines_at_start() {
517        let rule = MD012NoMultipleBlanks::default();
518        let content = "\n\n\nContent";
519        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
520        let result = rule.check(&ctx).unwrap();
521        assert_eq!(result.len(), 1, "one warning for the whole leading run");
522        assert_eq!((result[0].line, result[0].end_line), (2, 4));
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        // 3 blank lines after the code block: one warning covering the 2 extra lines
597        assert_eq!(warnings.len(), 1, "Should flag blanks after indented code block ends");
598        assert_eq!((warnings[0].line, warnings[0].end_line), (5, 7));
599    }
600
601    #[test]
602    fn test_fix_with_final_newline() {
603        let rule = MD012NoMultipleBlanks::default();
604        let content = "Line 1\n\n\nLine 2\n";
605        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606        let fixed = rule.fix(&ctx).unwrap();
607        assert_eq!(fixed, "Line 1\n\nLine 2\n");
608        assert!(fixed.ends_with('\n'));
609    }
610
611    #[test]
612    fn test_empty_content() {
613        let rule = MD012NoMultipleBlanks::default();
614        let content = "";
615        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
616        let result = rule.check(&ctx).unwrap();
617        assert!(result.is_empty());
618    }
619
620    #[test]
621    fn test_nested_code_blocks() {
622        let rule = MD012NoMultipleBlanks::default();
623        let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
624        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625        let result = rule.check(&ctx).unwrap();
626        assert!(result.is_empty());
627    }
628
629    #[test]
630    fn test_unclosed_code_block() {
631        let rule = MD012NoMultipleBlanks::default();
632        let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
633        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
634        let result = rule.check(&ctx).unwrap();
635        assert!(result.is_empty()); // Unclosed code blocks still preserve blank lines
636    }
637
638    #[test]
639    fn test_mixed_fence_styles() {
640        let rule = MD012NoMultipleBlanks::default();
641        let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
642        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643        let result = rule.check(&ctx).unwrap();
644        assert!(result.is_empty()); // Mixed fence styles should work
645    }
646
647    #[test]
648    fn test_config_from_toml() {
649        let mut config = crate::config::Config::default();
650        let mut rule_config = crate::config::RuleConfig::default();
651        rule_config
652            .values
653            .insert("maximum".to_string(), toml::Value::Integer(3));
654        config.rules.insert("MD012".to_string(), rule_config);
655
656        let rule = MD012NoMultipleBlanks::from_config(&config);
657        let content = "Line 1\n\n\n\nLine 2"; // 3 blank lines
658        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659        let result = rule.check(&ctx).unwrap();
660        assert!(result.is_empty()); // 3 blank lines allowed with maximum=3
661    }
662
663    #[test]
664    fn test_blank_lines_between_sections() {
665        // With heading limits from MD022, heading-adjacent excess is allowed up to the limit
666        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
667        let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
668        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669        let result = rule.check(&ctx).unwrap();
670        assert!(
671            result.is_empty(),
672            "2 blanks above heading allowed with heading_blanks_above=2"
673        );
674    }
675
676    #[test]
677    fn test_fix_preserves_indented_code() {
678        let rule = MD012NoMultipleBlanks::default();
679        let content = "Text\n\n\n    code\n    \n    more code\n\n\nText";
680        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
681        let fixed = rule.fix(&ctx).unwrap();
682        // Fix removes excess blank lines outside code blocks but preserves
683        // whitespace-only lines inside indented code blocks unchanged.
684        assert_eq!(fixed, "Text\n\n    code\n    \n    more code\n\nText");
685    }
686
687    #[test]
688    fn test_edge_case_only_blanks() {
689        let rule = MD012NoMultipleBlanks::default();
690        let content = "\n\n\n";
691        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692        let result = rule.check(&ctx).unwrap();
693        // With the new EOF handling, we report once at EOF
694        assert_eq!(result.len(), 1);
695        assert!(result[0].message.contains("at end of file"));
696    }
697
698    // Regression tests for blanks after code blocks (GitHub issue #199 related)
699
700    #[test]
701    fn test_blanks_after_fenced_code_block_mid_document() {
702        // Blanks between code block and heading use heading_above limit
703        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
704        let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
705        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
706        let result = rule.check(&ctx).unwrap();
707        assert!(
708            result.is_empty(),
709            "2 blanks before heading allowed with heading_blanks_above=2"
710        );
711    }
712
713    #[test]
714    fn test_blanks_after_code_block_at_eof() {
715        // Trailing blanks after code block at end of file
716        let rule = MD012NoMultipleBlanks::default();
717        let content = "# Heading\n\n```\ncode\n```\n\n\n";
718        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
719        let result = rule.check(&ctx).unwrap();
720        // Should flag the trailing blanks at EOF
721        assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
722        assert!(result[0].message.contains("at end of file"));
723    }
724
725    #[test]
726    fn test_single_blank_after_code_block_allowed() {
727        // Single blank after code block is allowed (default max=1)
728        let rule = MD012NoMultipleBlanks::default();
729        let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
730        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
731        let result = rule.check(&ctx).unwrap();
732        assert!(result.is_empty(), "Single blank after code block should be allowed");
733    }
734
735    #[test]
736    fn test_multiple_code_blocks_with_blanks() {
737        // Multiple code blocks, each followed by blanks
738        let rule = MD012NoMultipleBlanks::default();
739        let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
740        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741        let result = rule.check(&ctx).unwrap();
742        // Should flag both double-blank sequences
743        assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
744    }
745
746    #[test]
747    fn test_whitespace_only_lines_after_code_block_at_eof() {
748        // Whitespace-only lines (not just empty) after code block at EOF
749        // This matches the React repo pattern where lines have trailing spaces
750        let rule = MD012NoMultipleBlanks::default();
751        let content = "```\ncode\n```\n   \n   \n";
752        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
753        let result = rule.check(&ctx).unwrap();
754        assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
755        assert!(result[0].message.contains("at end of file"));
756    }
757
758    // Tests for warning-based fix (used by LSP formatting)
759
760    #[test]
761    fn test_warning_fix_removes_single_trailing_blank() {
762        // Regression test for issue #265: LSP formatting should work for EOF blanks
763        let rule = MD012NoMultipleBlanks::default();
764        let content = "hello foobar hello.\n\n";
765        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
766        let warnings = rule.check(&ctx).unwrap();
767
768        assert_eq!(warnings.len(), 1);
769        assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
770
771        let fix = warnings[0].fix.as_ref().unwrap();
772        // The fix should remove the trailing blank line
773        assert_eq!(fix.replacement, "", "Replacement should be empty");
774
775        // Apply the fix and verify result
776        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
777        assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
778    }
779
780    #[test]
781    fn test_warning_fix_removes_multiple_trailing_blanks() {
782        let rule = MD012NoMultipleBlanks::default();
783        let content = "content\n\n\n\n";
784        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785        let warnings = rule.check(&ctx).unwrap();
786
787        assert_eq!(warnings.len(), 1);
788        assert!(warnings[0].fix.is_some());
789
790        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
791        assert_eq!(fixed, "content\n", "Should end with single newline");
792    }
793
794    #[test]
795    fn test_warning_fix_preserves_content_newline() {
796        // Ensure the fix doesn't remove the content line's trailing newline
797        let rule = MD012NoMultipleBlanks::default();
798        let content = "line1\nline2\n\n";
799        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800        let warnings = rule.check(&ctx).unwrap();
801
802        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
803        assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
804    }
805
806    #[test]
807    fn test_warning_fix_mid_document_blanks() {
808        // With default limits (1,1), heading-adjacent excess blanks are flagged
809        let rule = MD012NoMultipleBlanks::default();
810        let content = "# Heading\n\n\n\nParagraph\n";
811        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812        let warnings = rule.check(&ctx).unwrap();
813        assert_eq!(
814            warnings.len(),
815            1,
816            "Excess heading-adjacent blanks flagged with default limits"
817        );
818        assert_eq!((warnings[0].line, warnings[0].end_line), (3, 5));
819        assert_eq!(
820            crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
821            "# Heading\n\nParagraph\n"
822        );
823    }
824
825    // Heading awareness tests
826    // MD012 reads MD022's config to determine heading blank line limits.
827    // When MD022 requires N blank lines around headings, MD012 allows up to N.
828
829    #[test]
830    fn test_heading_aware_blanks_below_with_higher_limit() {
831        // With heading_blanks_below = 2, 2 blanks below heading are allowed
832        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
833        let content = "# Heading\n\n\nParagraph\n";
834        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
835        let result = rule.check(&ctx).unwrap();
836        assert!(
837            result.is_empty(),
838            "2 blanks below heading allowed with heading_blanks_below=2"
839        );
840    }
841
842    #[test]
843    fn test_heading_aware_blanks_above_with_higher_limit() {
844        // With heading_blanks_above = 2, 2 blanks above heading are allowed
845        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
846        let content = "Paragraph\n\n\n# Heading\n";
847        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
848        let result = rule.check(&ctx).unwrap();
849        assert!(
850            result.is_empty(),
851            "2 blanks above heading allowed with heading_blanks_above=2"
852        );
853    }
854
855    #[test]
856    fn test_heading_aware_blanks_between_headings() {
857        // Between headings, use the larger of above/below limits
858        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
859        let content = "# Heading 1\n\n\n## Heading 2\n";
860        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
861        let result = rule.check(&ctx).unwrap();
862        assert!(result.is_empty(), "2 blanks between headings allowed with limits=2");
863    }
864
865    #[test]
866    fn test_heading_aware_excess_still_flagged() {
867        // Even with heading limits, excess beyond the limit is flagged
868        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
869        let content = "# Heading\n\n\n\n\nParagraph\n"; // 4 blanks, limit is 2
870        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
871        let result = rule.check(&ctx).unwrap();
872        assert_eq!(result.len(), 1, "Excess beyond heading limit should be flagged");
873        assert_eq!((result[0].line, result[0].end_line), (4, 6));
874    }
875
876    #[test]
877    fn test_heading_aware_setext_blanks_below() {
878        // Setext headings with heading limits
879        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
880        let content = "Heading\n=======\n\n\nParagraph\n";
881        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
882        let result = rule.check(&ctx).unwrap();
883        assert!(result.is_empty(), "2 blanks below Setext heading allowed with limit=2");
884    }
885
886    #[test]
887    fn test_heading_aware_setext_blanks_above() {
888        // Setext headings with heading limits
889        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
890        let content = "Paragraph\n\n\nHeading\n=======\n";
891        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
892        let result = rule.check(&ctx).unwrap();
893        assert!(result.is_empty(), "2 blanks above Setext heading allowed with limit=2");
894    }
895
896    #[test]
897    fn test_heading_aware_multi_line_setext_blanks_above() {
898        // A setext heading's text is the whole paragraph its underline ends, so
899        // the blanks sit above the first of those lines and get the same
900        // heading-adjacent allowance a single-line heading gets.
901        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
902        let content = "Paragraph\n\n\nFirst line\nsecond line\n=====\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 above a two-line setext heading allowed with limit=2. Got: {result:?}"
908        );
909    }
910
911    #[test]
912    fn test_heading_aware_single_blank_allowed() {
913        // 1 blank near heading is always allowed
914        let rule = MD012NoMultipleBlanks::default();
915        let content = "# Heading\n\nParagraph\n";
916        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917        let result = rule.check(&ctx).unwrap();
918        assert!(result.is_empty(), "Single blank near heading should be allowed");
919    }
920
921    #[test]
922    fn test_heading_aware_non_heading_blanks_still_flagged() {
923        // Blanks between non-heading content should still be flagged
924        let rule = MD012NoMultipleBlanks::default();
925        let content = "Paragraph 1\n\n\nParagraph 2\n";
926        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
927        let result = rule.check(&ctx).unwrap();
928        assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
929    }
930
931    #[test]
932    fn test_heading_aware_fix_caps_heading_blanks() {
933        // MD012 fix caps heading-adjacent blanks at effective max
934        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
935        let content = "# Heading\n\n\n\nParagraph\n"; // 3 blanks, limit below is 2
936        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
937        let fixed = rule.fix(&ctx).unwrap();
938        assert_eq!(
939            fixed, "# Heading\n\n\nParagraph\n",
940            "Fix caps heading-adjacent blanks at effective max (2)"
941        );
942    }
943
944    #[test]
945    fn test_heading_aware_fix_preserves_allowed_heading_blanks() {
946        // When blanks are within the heading limit, fix preserves them
947        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 3);
948        let content = "# Heading\n\n\n\nParagraph\n"; // 3 blanks, limit below is 3
949        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
950        let fixed = rule.fix(&ctx).unwrap();
951        assert_eq!(
952            fixed, "# Heading\n\n\n\nParagraph\n",
953            "Fix preserves blanks within the heading limit"
954        );
955    }
956
957    #[test]
958    fn test_heading_aware_fix_reduces_non_heading_blanks() {
959        // Fix should still reduce non-heading blanks
960        let rule = MD012NoMultipleBlanks::default();
961        let content = "Paragraph 1\n\n\n\nParagraph 2\n";
962        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
963        let fixed = rule.fix(&ctx).unwrap();
964        assert_eq!(
965            fixed, "Paragraph 1\n\nParagraph 2\n",
966            "Fix should reduce non-heading blanks"
967        );
968    }
969
970    #[test]
971    fn test_heading_aware_mixed_heading_and_non_heading() {
972        // With heading limits, heading-adjacent gaps use higher limit
973        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
974        let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
975        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976        let result = rule.check(&ctx).unwrap();
977        // heading->para gap (2 blanks, limit=2): ok. para->para gap (2 blanks, limit=1): flagged
978        assert_eq!(result.len(), 1, "Only non-heading excess should be flagged");
979    }
980
981    #[test]
982    fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
983        // Start-of-file blanks are always flagged, even before a heading.
984        // No rule requires blanks at the absolute start of a file.
985        let rule = MD012NoMultipleBlanks::default().with_heading_limits(3, 3);
986        let content = "\n\n\n# Heading\n";
987        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988        let result = rule.check(&ctx).unwrap();
989        assert_eq!(
990            result.len(),
991            1,
992            "Start-of-file blanks should be flagged even before heading"
993        );
994        assert_eq!((result[0].line, result[0].end_line), (2, 4));
995        assert!(result[0].message.contains("at start of file"));
996    }
997
998    #[test]
999    fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
1000        // EOF blanks should still be flagged even after a heading
1001        let rule = MD012NoMultipleBlanks::default();
1002        let content = "# Heading\n\n";
1003        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004        let result = rule.check(&ctx).unwrap();
1005        assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
1006        assert!(result[0].message.contains("at end of file"));
1007    }
1008
1009    #[test]
1010    fn test_heading_aware_unlimited_heading_blanks() {
1011        // With usize::MAX heading limit (Unlimited in MD022), MD012 never flags heading-adjacent
1012        let rule = MD012NoMultipleBlanks::default().with_heading_limits(usize::MAX, usize::MAX);
1013        let content = "# Heading\n\n\n\n\nParagraph\n"; // 4 blanks below heading
1014        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1015        let result = rule.check(&ctx).unwrap();
1016        assert!(
1017            result.is_empty(),
1018            "Unlimited heading limits means MD012 never flags near headings"
1019        );
1020    }
1021
1022    #[test]
1023    fn test_heading_aware_blanks_after_code_then_heading() {
1024        // Blanks after code block are not heading-adjacent (prev_content_line_num reset)
1025        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1026        let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
1027        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1028        let result = rule.check(&ctx).unwrap();
1029        // The blanks are between code block and "More text" (not heading-adjacent)
1030        assert_eq!(result.len(), 1, "Non-heading blanks after code block should be flagged");
1031        assert_eq!((result[0].line, result[0].end_line), (7, 9));
1032    }
1033
1034    #[test]
1035    fn test_heading_aware_fix_mixed_document() {
1036        // MD012 fix with heading limits
1037        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1038        let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
1039        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1040        let fixed = rule.fix(&ctx).unwrap();
1041        // Heading-adjacent blanks preserved (within limit=2), non-heading blanks reduced
1042        assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
1043    }
1044
1045    #[test]
1046    fn test_heading_aware_from_config_reads_md022() {
1047        // from_config reads MD022 config to determine heading limits
1048        let mut config = crate::config::Config::default();
1049        let mut md022_config = crate::config::RuleConfig::default();
1050        md022_config
1051            .values
1052            .insert("lines-above".to_string(), toml::Value::Integer(2));
1053        md022_config
1054            .values
1055            .insert("lines-below".to_string(), toml::Value::Integer(3));
1056        config.rules.insert("MD022".to_string(), md022_config);
1057
1058        let rule = MD012NoMultipleBlanks::from_config(&config);
1059        // With MD022 lines-above=2: 2 blanks above heading should be allowed
1060        let content = "Paragraph\n\n\n# Heading\n";
1061        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1062        let result = rule.check(&ctx).unwrap();
1063        assert!(
1064            result.is_empty(),
1065            "2 blanks above heading allowed when MD022 lines-above=2"
1066        );
1067    }
1068
1069    #[test]
1070    fn test_heading_aware_from_config_reads_nested_md022_limit() {
1071        let mut config = crate::config::Config::default();
1072        let mut md022_config = crate::config::RuleConfig::default();
1073        md022_config
1074            .values
1075            .insert("nested-heading-lines-above".to_string(), toml::Value::Integer(3));
1076        config.rules.insert("MD022".to_string(), md022_config);
1077
1078        let rule = MD012NoMultipleBlanks::from_config(&config);
1079        let content = "# Parent\n\n\n\n## Child";
1080        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1081        assert!(rule.check(&ctx).unwrap().is_empty());
1082    }
1083
1084    #[test]
1085    fn test_heading_aware_from_config_md022_disabled() {
1086        // When MD022 is disabled, MD012 uses its own maximum everywhere
1087        let mut config = crate::config::Config::default();
1088        config.global.disable.push("MD022".to_string());
1089
1090        let mut md022_config = crate::config::RuleConfig::default();
1091        md022_config
1092            .values
1093            .insert("lines-above".to_string(), toml::Value::Integer(3));
1094        config.rules.insert("MD022".to_string(), md022_config);
1095
1096        let rule = MD012NoMultipleBlanks::from_config(&config);
1097        // MD022 disabled: heading-adjacent blanks treated like any other
1098        let content = "Paragraph\n\n\n# Heading\n";
1099        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1100        let result = rule.check(&ctx).unwrap();
1101        assert_eq!(
1102            result.len(),
1103            1,
1104            "With MD022 disabled, heading-adjacent blanks are flagged"
1105        );
1106    }
1107
1108    #[test]
1109    fn test_heading_aware_from_config_md022_unlimited() {
1110        // When MD022 has lines-above = -1 (Unlimited), MD012 never flags above headings
1111        let mut config = crate::config::Config::default();
1112        let mut md022_config = crate::config::RuleConfig::default();
1113        md022_config
1114            .values
1115            .insert("lines-above".to_string(), toml::Value::Integer(-1));
1116        config.rules.insert("MD022".to_string(), md022_config);
1117
1118        let rule = MD012NoMultipleBlanks::from_config(&config);
1119        let content = "Paragraph\n\n\n\n\n# Heading\n"; // 4 blanks above heading
1120        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1121        let result = rule.check(&ctx).unwrap();
1122        assert!(
1123            result.is_empty(),
1124            "Unlimited MD022 lines-above means MD012 never flags above headings"
1125        );
1126    }
1127
1128    #[test]
1129    fn test_heading_aware_from_config_per_level() {
1130        // Per-level config: max_heading_limit takes the maximum across all levels.
1131        // lines-above = [2, 1, 1, 1, 1, 1] → heading_blanks_above = 2 (max of all levels).
1132        // This means 2 blanks above ANY heading is allowed, even if only H1 needs 2.
1133        // This is a deliberate trade-off: conservative (no false positives from MD012).
1134        let mut config = crate::config::Config::default();
1135        let mut md022_config = crate::config::RuleConfig::default();
1136        md022_config.values.insert(
1137            "lines-above".to_string(),
1138            toml::Value::Array(vec![
1139                toml::Value::Integer(2),
1140                toml::Value::Integer(1),
1141                toml::Value::Integer(1),
1142                toml::Value::Integer(1),
1143                toml::Value::Integer(1),
1144                toml::Value::Integer(1),
1145            ]),
1146        );
1147        config.rules.insert("MD022".to_string(), md022_config);
1148
1149        let rule = MD012NoMultipleBlanks::from_config(&config);
1150
1151        // 2 blanks above H2: MD012 allows it (max across levels is 2)
1152        let content = "Paragraph\n\n\n## H2 Heading\n";
1153        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1154        let result = rule.check(&ctx).unwrap();
1155        assert!(result.is_empty(), "Per-level max (2) allows 2 blanks above any heading");
1156
1157        // 3 blanks above H2: exceeds the per-level max of 2
1158        let content = "Paragraph\n\n\n\n## H2 Heading\n";
1159        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160        let result = rule.check(&ctx).unwrap();
1161        assert_eq!(result.len(), 1, "3 blanks exceeds per-level max of 2");
1162    }
1163
1164    #[test]
1165    fn test_issue_449_reproduction() {
1166        // Exact reproduction case from GitHub issue #449.
1167        // With default settings, excess blanks around headings should be flagged.
1168        let rule = MD012NoMultipleBlanks::default();
1169        let content = "\
1170# Heading
1171
1172
1173Some introductory text.
1174
1175
1176
1177
1178
1179## Heading level 2
1180
1181
1182Some text for this section.
1183
1184Some more text for this section.
1185
1186
1187## Another heading level 2
1188
1189
1190
1191Some text for this section.
1192
1193Some more text for this section.
1194";
1195        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196        let result = rule.check(&ctx).unwrap();
1197        assert!(
1198            !result.is_empty(),
1199            "Issue #449: excess blanks around headings should be flagged with default settings"
1200        );
1201
1202        // Verify fix produces clean output
1203        let fixed = rule.fix(&ctx).unwrap();
1204        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1205        let recheck = rule.check(&fixed_ctx).unwrap();
1206        assert!(recheck.is_empty(), "Fix should resolve all excess blank lines");
1207
1208        // Verify the fixed output has exactly 1 blank line around each heading
1209        assert!(fixed.contains("# Heading\n\nSome"), "1 blank below first heading");
1210        assert!(
1211            fixed.contains("text.\n\n## Heading level 2"),
1212            "1 blank above second heading"
1213        );
1214    }
1215
1216    // Quarto flavor tests
1217
1218    #[test]
1219    fn test_blank_lines_in_quarto_callout() {
1220        // Blank lines inside Quarto callout blocks should be allowed
1221        let rule = MD012NoMultipleBlanks::default();
1222        let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
1223        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1224        let result = rule.check(&ctx).unwrap();
1225        assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
1226    }
1227
1228    #[test]
1229    fn test_blank_lines_in_quarto_div() {
1230        // Blank lines inside generic Quarto divs should be allowed
1231        let rule = MD012NoMultipleBlanks::default();
1232        let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
1233        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1234        let result = rule.check(&ctx).unwrap();
1235        assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
1236    }
1237
1238    #[test]
1239    fn test_blank_lines_outside_quarto_div_flagged() {
1240        // Blank lines outside Quarto divs should still be flagged
1241        let rule = MD012NoMultipleBlanks::default();
1242        let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
1243        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1244        let result = rule.check(&ctx).unwrap();
1245        assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1246    }
1247
1248    #[test]
1249    fn test_quarto_divs_ignored_in_standard_flavor() {
1250        // In standard flavor, Quarto div syntax is not special
1251        let rule = MD012NoMultipleBlanks::default();
1252        let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1253        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254        let result = rule.check(&ctx).unwrap();
1255        // In standard flavor, the triple blank inside "div" is flagged
1256        assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1257    }
1258
1259    // Roundtrip safety tests: fix then re-check = 0 violations
1260
1261    #[test]
1262    fn test_roundtrip_multiple_blank_lines() {
1263        let rule = MD012NoMultipleBlanks::default();
1264        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
1265        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266        let fixed = rule.fix(&ctx).unwrap();
1267        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1268        let recheck = rule.check(&ctx2).unwrap();
1269        assert!(
1270            recheck.is_empty(),
1271            "Roundtrip: fix then check should be clean, got {recheck:?}"
1272        );
1273    }
1274
1275    #[test]
1276    fn test_roundtrip_trailing_blanks() {
1277        let rule = MD012NoMultipleBlanks::default();
1278        let content = "Content\n\n\n\n";
1279        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1280        let fixed = rule.fix(&ctx).unwrap();
1281        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1282        let recheck = rule.check(&ctx2).unwrap();
1283        assert!(recheck.is_empty(), "Roundtrip: trailing blanks, got {recheck:?}");
1284    }
1285
1286    #[test]
1287    fn test_roundtrip_leading_blanks() {
1288        let rule = MD012NoMultipleBlanks::default();
1289        let content = "\n\n\nContent\n";
1290        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1291        let fixed = rule.fix(&ctx).unwrap();
1292        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1293        let recheck = rule.check(&ctx2).unwrap();
1294        assert!(recheck.is_empty(), "Roundtrip: leading blanks, got {recheck:?}");
1295    }
1296
1297    #[test]
1298    fn test_roundtrip_custom_maximum() {
1299        let rule = MD012NoMultipleBlanks::new(2);
1300        let content = "Line 1\n\n\n\n\nLine 2\n";
1301        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1302        let fixed = rule.fix(&ctx).unwrap();
1303        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1304        let recheck = rule.check(&ctx2).unwrap();
1305        assert!(recheck.is_empty(), "Roundtrip: max=2, got {recheck:?}");
1306    }
1307
1308    #[test]
1309    fn test_roundtrip_code_blocks() {
1310        let rule = MD012NoMultipleBlanks::default();
1311        let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
1312        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1313        let fixed = rule.fix(&ctx).unwrap();
1314        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1315        let recheck = rule.check(&ctx2).unwrap();
1316        assert!(recheck.is_empty(), "Roundtrip: code blocks, got {recheck:?}");
1317    }
1318
1319    #[test]
1320    fn test_roundtrip_heading_limits() {
1321        let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1322        let content = "# Heading\n\n\n\n\nParagraph\n\n\n\n## Heading 2\n";
1323        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1324        let fixed = rule.fix(&ctx).unwrap();
1325        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1326        let recheck = rule.check(&ctx2).unwrap();
1327        assert!(recheck.is_empty(), "Roundtrip: heading limits, got {recheck:?}");
1328    }
1329
1330    #[test]
1331    fn test_roundtrip_front_matter() {
1332        let rule = MD012NoMultipleBlanks::default();
1333        let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\n\n\nContent\n";
1334        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1335        let fixed = rule.fix(&ctx).unwrap();
1336        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1337        let recheck = rule.check(&ctx2).unwrap();
1338        assert!(recheck.is_empty(), "Roundtrip: front matter, got {recheck:?}");
1339    }
1340
1341    #[test]
1342    fn test_roundtrip_only_blanks() {
1343        let rule = MD012NoMultipleBlanks::default();
1344        let content = "\n\n\n";
1345        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1346        let fixed = rule.fix(&ctx).unwrap();
1347        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1348        let recheck = rule.check(&ctx2).unwrap();
1349        assert!(recheck.is_empty(), "Roundtrip: only blanks, got {recheck:?}");
1350    }
1351
1352    #[test]
1353    fn test_roundtrip_single_eof_blank() {
1354        let rule = MD012NoMultipleBlanks::default();
1355        let content = "Content\n\n";
1356        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357        let fixed = rule.fix(&ctx).unwrap();
1358        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1359        let recheck = rule.check(&ctx2).unwrap();
1360        assert!(recheck.is_empty(), "Roundtrip: single EOF blank, got {recheck:?}");
1361    }
1362
1363    #[test]
1364    fn test_roundtrip_mixed_heading_and_non_heading() {
1365        let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
1366        let content = "# Heading\n\n\n\nParagraph 1\n\n\n\nParagraph 2\n";
1367        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1368        let fixed = rule.fix(&ctx).unwrap();
1369        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1370        let recheck = rule.check(&ctx2).unwrap();
1371        assert!(
1372            recheck.is_empty(),
1373            "Roundtrip: mixed heading/non-heading, got {recheck:?}"
1374        );
1375    }
1376
1377    #[test]
1378    fn literal_template_blank_lines_are_preserved_beside_prose_fixes() {
1379        let rule = MD012NoMultipleBlanks::default();
1380        for (flavor, code) in [
1381            (
1382                crate::config::MarkdownFlavor::Standard,
1383                "{% set unused=\"first\n\n\nlast\" %}{{ unused|length }}",
1384            ),
1385            (
1386                crate::config::MarkdownFlavor::Hugo,
1387                "{{< note title=`first\n\n\nlast` >}}",
1388            ),
1389        ] {
1390            for ending in ["\n", "\r\n"] {
1391                let source = format!("{code}\n\n\nVisible.\n").replace('\n', ending);
1392                let expected = format!("{code}\n\nVisible.\n").replace('\n', ending);
1393                let ctx = LintContext::new(&source, flavor, None);
1394                let warnings = rule.check(&ctx).unwrap();
1395                assert_eq!(warnings.len(), 1, "{source}");
1396                assert_eq!(warnings[0].line, code.lines().count() + 2);
1397                assert_eq!(rule.fix(&ctx).unwrap(), expected);
1398            }
1399        }
1400    }
1401
1402    #[test]
1403    fn whitespace_only_template_lines_do_not_join_prose_blank_runs() {
1404        let rule = MD012NoMultipleBlanks::default();
1405        for code in [
1406            "{% set unused=\"first\n \n  \nlast\" %}{{ unused|length }}",
1407            "{{< note title=`first\n \n  \nlast` >}}",
1408        ] {
1409            let flavor = if code.starts_with("{{<") {
1410                crate::config::MarkdownFlavor::Hugo
1411            } else {
1412                crate::config::MarkdownFlavor::Standard
1413            };
1414            let source = format!("Before.\n\n\n{code}\n\n\nAfter.\n\n");
1415            let expected = format!("Before.\n\n{code}\n\nAfter.\n");
1416            let ctx = LintContext::new(&source, flavor, None);
1417            assert_eq!(rule.check(&ctx).unwrap().len(), 3, "{source}");
1418            assert_eq!(rule.fix(&ctx).unwrap(), expected);
1419        }
1420    }
1421
1422    #[test]
1423    fn template_boundaries_keep_ordinary_blank_limits_and_inline_config() {
1424        let rule = MD012NoMultipleBlanks::default();
1425        for (source, expected) in [
1426            (
1427                "{% if enabled %}\nVisible.\n\n\nText.\n{% endif %}\n",
1428                "{% if enabled %}\nVisible.\n\nText.\n{% endif %}\n",
1429            ),
1430            ("{{ title }}\n\n\nVisible.\n", "{{ title }}\n\nVisible.\n"),
1431            ("{{ title }}\n\n", "{{ title }}\n"),
1432            (
1433                "<!-- rumdl-disable MD012 -->\n{{ title }}\n\n\nVisible.\n",
1434                "<!-- rumdl-disable MD012 -->\n{{ title }}\n\n\nVisible.\n",
1435            ),
1436        ] {
1437            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1438            assert_eq!(rule.fix(&ctx).unwrap(), expected);
1439        }
1440        let rule = rule.with_heading_limits(3, 3);
1441        let source = "# Heading\n\n\n{% set unused=\"first\n\nlast\" %}{{ unused|length }}\n\n\n# Next\n\nBody.\n";
1442        let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1443        assert!(rule.check(&ctx).unwrap().is_empty());
1444        assert_eq!(rule.fix(&ctx).unwrap(), source);
1445    }
1446
1447    #[test]
1448    fn test_unicode_space_line_is_a_paragraph_line_not_blank() {
1449        let rule = MD012NoMultipleBlanks::default();
1450        for src in ["a\n\n\u{a0}\n\nb\n", "a\n\n\u{2003}\n\n\nb\n\u{3000}\n"] {
1451            let ctx = LintContext::new(src, crate::config::MarkdownFlavor::Standard, None);
1452            let fixed = rule.fix(&ctx).unwrap();
1453            for c in ['\u{a0}', '\u{2003}', '\u{3000}'] {
1454                assert_eq!(fixed.matches(c).count(), src.matches(c).count(), "{src:?} -> {fixed:?}");
1455            }
1456        }
1457    }
1458
1459    #[test]
1460    fn test_ascii_blank_lines_beside_unicode_space_line_are_still_collapsed() {
1461        let rule = MD012NoMultipleBlanks::default();
1462        let src = "a\n\n \t\n\n\u{a0}\nb\n";
1463        let ctx = LintContext::new(src, crate::config::MarkdownFlavor::Standard, None);
1464        assert_eq!(rule.fix(&ctx).unwrap(), "a\n\n\u{a0}\nb\n");
1465    }
1466
1467    /// One run of blank lines is one problem, so it is reported once.
1468    mod run_reporting {
1469        use super::*;
1470        use crate::config::MarkdownFlavor;
1471
1472        /// (line, column, end_line, end_column) of a warning.
1473        fn span(warning: &LintWarning) -> (usize, usize, usize, usize) {
1474            (warning.line, warning.column, warning.end_line, warning.end_column)
1475        }
1476
1477        fn check(rule: &MD012NoMultipleBlanks, content: &str) -> Vec<LintWarning> {
1478            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1479            rule.check(&ctx).unwrap()
1480        }
1481
1482        #[test]
1483        fn mid_document_run_is_one_warning_covering_every_excess_line() {
1484            // Lines 2-4 are blank; with maximum 1, lines 3 and 4 are excess.
1485            let warnings = check(&MD012NoMultipleBlanks::default(), "Line 1\n\n\n\nLine 2\n");
1486            assert_eq!(warnings.len(), 1, "{warnings:?}");
1487            assert_eq!(span(&warnings[0]), (3, 1, 5, 1));
1488            assert_eq!(
1489                warnings[0].message,
1490                "Multiple consecutive blank lines between content (3 found, 1 allowed)"
1491            );
1492        }
1493
1494        #[test]
1495        fn single_excess_line_still_has_a_non_empty_range() {
1496            let warnings = check(&MD012NoMultipleBlanks::default(), "Line 1\n\n\nLine 2\n");
1497            assert_eq!(warnings.len(), 1, "{warnings:?}");
1498            assert_eq!(span(&warnings[0]), (3, 1, 4, 1));
1499            assert_eq!(
1500                warnings[0].message,
1501                "Multiple consecutive blank lines between content (2 found, 1 allowed)"
1502            );
1503        }
1504
1505        #[test]
1506        fn separate_runs_are_separate_warnings() {
1507            let warnings = check(&MD012NoMultipleBlanks::default(), "a\n\n\nb\n\n\n\nc\n");
1508            let spans: Vec<_> = warnings.iter().map(span).collect();
1509            assert_eq!(spans, [(3, 1, 4, 1), (6, 1, 8, 1)], "{warnings:?}");
1510        }
1511
1512        #[test]
1513        fn warning_fix_deletes_exactly_the_excess_lines() {
1514            for (source, expected) in [
1515                ("Line 1\n\n\n\nLine 2\n", "Line 1\n\nLine 2\n"),
1516                ("Line 1\r\n\r\n\r\n\r\nLine 2\r\n", "Line 1\r\n\r\nLine 2\r\n"),
1517                ("Line 1\n\n \t\n  \nLine 2\n", "Line 1\n\nLine 2\n"),
1518            ] {
1519                let rule = MD012NoMultipleBlanks::default();
1520                let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1521                let warnings = rule.check(&ctx).unwrap();
1522                assert_eq!(warnings.len(), 1, "{source:?}: {warnings:?}");
1523                let fixed = crate::utils::fix_utils::apply_warning_fixes(source, &warnings).unwrap();
1524                assert_eq!(fixed, expected, "{source:?}");
1525            }
1526        }
1527
1528        #[test]
1529        fn custom_maximum_reports_the_configured_allowance() {
1530            let warnings = check(&MD012NoMultipleBlanks::new(2), "a\n\n\n\n\nb\n");
1531            assert_eq!(warnings.len(), 1, "{warnings:?}");
1532            assert_eq!(span(&warnings[0]), (4, 1, 6, 1));
1533            assert_eq!(
1534                warnings[0].message,
1535                "Multiple consecutive blank lines between content (4 found, 2 allowed)"
1536            );
1537        }
1538
1539        #[test]
1540        fn heading_adjacent_run_reports_the_md022_derived_allowance() {
1541            let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
1542            let warnings = check(&rule, "# Title\n\n\n\n\nText\n");
1543            assert_eq!(warnings.len(), 1, "{warnings:?}");
1544            assert_eq!(span(&warnings[0]), (4, 1, 6, 1));
1545            assert_eq!(
1546                warnings[0].message,
1547                "Multiple consecutive blank lines between content (4 found, 2 allowed)"
1548            );
1549        }
1550
1551        #[test]
1552        fn start_of_file_run_is_one_warning() {
1553            let warnings = check(&MD012NoMultipleBlanks::default(), "\n\n\nText\n");
1554            assert_eq!(warnings.len(), 1, "{warnings:?}");
1555            assert_eq!(span(&warnings[0]), (2, 1, 4, 1));
1556            assert_eq!(
1557                warnings[0].message,
1558                "Multiple consecutive blank lines at start of file (3 found, 1 allowed)"
1559            );
1560        }
1561
1562        #[test]
1563        fn run_before_a_skipped_region_is_one_warning() {
1564            let warnings = check(&MD012NoMultipleBlanks::default(), "Text\n\n\n\n```\ncode\n```\n");
1565            assert_eq!(warnings.len(), 1, "{warnings:?}");
1566            assert_eq!(span(&warnings[0]), (3, 1, 5, 1));
1567        }
1568
1569        #[test]
1570        fn end_of_file_range_starts_at_the_first_trailing_blank_and_ends_at_eof() {
1571            for (source, expected_span, message) in [
1572                (
1573                    "Text\n\n",
1574                    (2, 1, 3, 1),
1575                    "Blank line at end of file (1 found, 0 allowed)",
1576                ),
1577                (
1578                    "Text\n\n\n",
1579                    (2, 1, 4, 1),
1580                    "Blank lines at end of file (2 found, 0 allowed)",
1581                ),
1582                (
1583                    "Text\r\n\r\n\r\n",
1584                    (2, 1, 4, 1),
1585                    "Blank lines at end of file (2 found, 0 allowed)",
1586                ),
1587                // An unterminated final line ends the range after its last character.
1588                (
1589                    "Text\n\n  ",
1590                    (2, 1, 3, 3),
1591                    "Blank lines at end of file (2 found, 0 allowed)",
1592                ),
1593            ] {
1594                let warnings = check(&MD012NoMultipleBlanks::default(), source);
1595                assert_eq!(warnings.len(), 1, "{source:?}: {warnings:?}");
1596                assert_eq!(span(&warnings[0]), expected_span, "{source:?}");
1597                assert_eq!(warnings[0].message, message, "{source:?}");
1598            }
1599        }
1600
1601        /// A `disable-line` on the content line after a run must not hide the run:
1602        /// the range ends at column 1 of that line and covers none of its text.
1603        #[test]
1604        fn disable_line_on_the_following_content_line_does_not_suppress_the_run() {
1605            let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD012NoMultipleBlanks::default())];
1606            let source = "Para\n\n\n\nNext <!-- rumdl-disable-line MD012 -->\n";
1607            let warnings = crate::lint(source, &rules, false, MarkdownFlavor::Standard, None, None).unwrap();
1608            assert_eq!(warnings.len(), 1, "{warnings:?}");
1609            assert_eq!(span(&warnings[0]), (3, 1, 5, 1));
1610
1611            let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1612            assert_eq!(
1613                MD012NoMultipleBlanks::default().fix(&ctx).unwrap(),
1614                "Para\n\nNext <!-- rumdl-disable-line MD012 -->\n"
1615            );
1616        }
1617
1618        /// Control for the test above: a disable that does cover the run still applies.
1619        #[test]
1620        fn disable_block_around_the_run_suppresses_it() {
1621            let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD012NoMultipleBlanks::default())];
1622            let source = "Para\n<!-- rumdl-disable MD012 -->\n\n\n\nNext\n<!-- rumdl-enable MD012 -->\n";
1623            let warnings = crate::lint(source, &rules, false, MarkdownFlavor::Standard, None, None).unwrap();
1624            assert!(warnings.is_empty(), "{warnings:?}");
1625            let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1626            assert_eq!(MD012NoMultipleBlanks::default().fix(&ctx).unwrap(), source);
1627        }
1628
1629        /// At end of file every trailing blank is excess, so a `disable-next-line`
1630        /// directly above them targets the first line the warning covers and
1631        /// suppresses the run, however many blank lines it holds.
1632        #[test]
1633        fn disable_next_line_above_trailing_blanks_suppresses_them() {
1634            let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD012NoMultipleBlanks::default())];
1635            for source in [
1636                "Text\n<!-- rumdl-disable-next-line MD012 -->\n\n",
1637                "Text\n<!-- rumdl-disable-next-line MD012 -->\n\n\n\n",
1638            ] {
1639                let warnings = crate::lint(source, &rules, false, MarkdownFlavor::Standard, None, None).unwrap();
1640                assert!(warnings.is_empty(), "{source:?}: {warnings:?}");
1641                let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1642                assert_eq!(MD012NoMultipleBlanks::default().fix(&ctx).unwrap(), source);
1643            }
1644        }
1645
1646        #[test]
1647        fn lsp_diagnostic_covers_the_excess_lines_with_their_line_endings() {
1648            let source = "Line 1\n\n\n\nLine 2\n";
1649            let warnings = check(&MD012NoMultipleBlanks::default(), source);
1650            let diagnostic = crate::lsp::types::warning_to_diagnostic(&warnings[0], source);
1651            assert_eq!(
1652                (
1653                    diagnostic.range.start.line,
1654                    diagnostic.range.start.character,
1655                    diagnostic.range.end.line,
1656                    diagnostic.range.end.character
1657                ),
1658                (2, 0, 4, 0)
1659            );
1660        }
1661    }
1662}