Skip to main content

rumdl_lib/rules/
md012_no_multiple_blanks.rs

1use crate::filtered_lines::FilteredLinesExt;
2use crate::lint_context::LintContext;
3use crate::lint_context::types::HeadingStyle;
4use crate::utils::LineIndex;
5use crate::utils::range_utils::calculate_line_range;
6use std::collections::HashSet;
7use toml;
8
9use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
10use crate::rule_config_serde::RuleConfig;
11
12mod md012_config;
13use md012_config::MD012Config;
14
15/// Rule MD012: No multiple consecutive blank lines
16///
17/// See [docs/md012.md](../../docs/md012.md) for full documentation, configuration, and examples.
18
19#[derive(Debug, Clone, Default)]
20pub struct MD012NoMultipleBlanks {
21    config: MD012Config,
22}
23
24impl MD012NoMultipleBlanks {
25    pub fn new(maximum: usize) -> Self {
26        use crate::types::PositiveUsize;
27        Self {
28            config: MD012Config {
29                maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
30            },
31        }
32    }
33
34    pub const fn from_config_struct(config: MD012Config) -> Self {
35        Self { config }
36    }
37
38    /// Generate warnings for excess blank lines, handling common logic for all contexts
39    fn generate_excess_warnings(
40        &self,
41        blank_start: usize,
42        blank_count: usize,
43        lines: &[&str],
44        lines_to_check: &HashSet<usize>,
45        line_index: &LineIndex,
46    ) -> Vec<LintWarning> {
47        let mut warnings = Vec::new();
48
49        let location = if blank_start == 0 {
50            "at start of file"
51        } else {
52            "between content"
53        };
54
55        for i in self.config.maximum.get()..blank_count {
56            let excess_line_num = blank_start + i;
57            if lines_to_check.contains(&excess_line_num) {
58                let excess_line = excess_line_num + 1;
59                let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
60                let (start_line, start_col, end_line, end_col) = calculate_line_range(excess_line, excess_line_content);
61                warnings.push(LintWarning {
62                    rule_name: Some(self.name().to_string()),
63                    severity: Severity::Warning,
64                    message: format!("Multiple consecutive blank lines {location}"),
65                    line: start_line,
66                    column: start_col,
67                    end_line,
68                    end_column: end_col,
69                    fix: Some(Fix {
70                        range: {
71                            let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
72                            let line_end = line_index
73                                .get_line_start_byte(excess_line + 1)
74                                .unwrap_or(line_start + 1);
75                            line_start..line_end
76                        },
77                        replacement: String::new(),
78                    }),
79                });
80            }
81        }
82
83        warnings
84    }
85}
86
87/// Check if the given 0-based line index is part of a heading.
88///
89/// Returns true if:
90/// - The line has heading info (covers ATX headings and Setext text lines), OR
91/// - The previous line is a Setext heading text line (covers the Setext underline)
92fn is_heading_context(ctx: &LintContext, line_idx: usize) -> bool {
93    if ctx.lines.get(line_idx).is_some_and(|li| li.heading.is_some()) {
94        return true;
95    }
96    // Check if previous line is a Setext heading text line — if so, this line is the underline
97    if line_idx > 0
98        && let Some(prev_info) = ctx.lines.get(line_idx - 1)
99        && let Some(ref heading) = prev_info.heading
100        && matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2)
101    {
102        return true;
103    }
104    false
105}
106
107impl Rule for MD012NoMultipleBlanks {
108    fn name(&self) -> &'static str {
109        "MD012"
110    }
111
112    fn description(&self) -> &'static str {
113        "Multiple consecutive blank lines"
114    }
115
116    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
117        let content = ctx.content;
118
119        // Early return for empty content
120        if content.is_empty() {
121            return Ok(Vec::new());
122        }
123
124        // Quick check for consecutive newlines or potential whitespace-only lines before processing
125        // Look for multiple consecutive lines that could be blank (empty or whitespace-only)
126        let lines = ctx.raw_lines();
127        let has_potential_blanks = lines
128            .windows(2)
129            .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
130
131        // Also check for blanks at EOF (markdownlint behavior)
132        // Content is normalized to LF at I/O boundary
133        let ends_with_multiple_newlines = content.ends_with("\n\n");
134
135        if !has_potential_blanks && !ends_with_multiple_newlines {
136            return Ok(Vec::new());
137        }
138
139        let line_index = &ctx.line_index;
140
141        let mut warnings = Vec::new();
142
143        // Single-pass algorithm with immediate counter reset
144        let mut blank_count = 0;
145        let mut blank_start = 0;
146        let mut last_line_num: Option<usize> = None;
147        // Track the last non-blank content line for heading adjacency checks
148        let mut prev_content_line_num: Option<usize> = None;
149
150        // Use HashSet for O(1) lookups of lines that need to be checked
151        let mut lines_to_check: HashSet<usize> = HashSet::new();
152
153        // Use filtered_lines to automatically skip front-matter, code blocks, Quarto divs, math blocks,
154        // PyMdown blocks, and Obsidian comments.
155        // The in_code_block field in LineInfo is pre-computed using pulldown-cmark
156        // and correctly handles both fenced code blocks and indented code blocks.
157        // Flavor-specific fields (in_quarto_div, in_pymdown_block, in_obsidian_comment) are only
158        // set for their respective flavors, so the skip filters have no effect otherwise.
159        for filtered_line in ctx
160            .filtered_lines()
161            .skip_front_matter()
162            .skip_code_blocks()
163            .skip_quarto_divs()
164            .skip_math_blocks()
165            .skip_obsidian_comments()
166            .skip_pymdown_blocks()
167        {
168            let line_num = filtered_line.line_num - 1; // Convert 1-based to 0-based for internal tracking
169            let line = filtered_line.content;
170
171            // Detect when lines were skipped (e.g., code block content)
172            // If we jump more than 1 line, there was content between, which breaks blank sequences
173            if let Some(last) = last_line_num
174                && line_num > last + 1
175            {
176                // Lines were skipped (code block or similar)
177                // Generate warnings for any accumulated blanks before the skip
178                if blank_count > self.config.maximum.get() {
179                    let heading_adjacent = prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx));
180                    if !heading_adjacent {
181                        warnings.extend(self.generate_excess_warnings(
182                            blank_start,
183                            blank_count,
184                            lines,
185                            &lines_to_check,
186                            line_index,
187                        ));
188                    }
189                }
190                blank_count = 0;
191                lines_to_check.clear();
192                // Reset heading context across skipped regions (code blocks, etc.)
193                prev_content_line_num = None;
194            }
195            last_line_num = Some(line_num);
196
197            if line.trim().is_empty() {
198                if blank_count == 0 {
199                    blank_start = line_num;
200                }
201                blank_count += 1;
202                // Store line numbers that exceed the limit
203                if blank_count > self.config.maximum.get() {
204                    lines_to_check.insert(line_num);
205                }
206            } else {
207                if blank_count > self.config.maximum.get() {
208                    // Skip warnings if blanks are between content and a heading.
209                    // Start-of-file blanks (blank_start == 0) before a heading are still
210                    // flagged — no MD022 config requires blanks at the start of a file.
211                    let heading_adjacent = prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx))
212                        || (blank_start > 0 && is_heading_context(ctx, line_num));
213                    if !heading_adjacent {
214                        warnings.extend(self.generate_excess_warnings(
215                            blank_start,
216                            blank_count,
217                            lines,
218                            &lines_to_check,
219                            line_index,
220                        ));
221                    }
222                }
223                blank_count = 0;
224                lines_to_check.clear();
225                prev_content_line_num = Some(line_num);
226            }
227        }
228
229        // Handle trailing blanks at EOF
230        // Main loop only reports mid-document blanks (between content)
231        // EOF handler reports trailing blanks with stricter rules (any blank at EOF is flagged)
232        //
233        // The blank_count at end of loop might include blanks BEFORE a code block at EOF,
234        // which aren't truly "trailing blanks". We need to verify the actual last line is blank.
235        let last_line_is_blank = lines.last().is_some_and(|l| l.trim().is_empty());
236
237        // Check for trailing blank lines
238        // EOF semantics: ANY blank line at EOF should be flagged (stricter than mid-document)
239        // Only fire if the actual last line(s) of the file are blank
240        if blank_count > 0 && last_line_is_blank {
241            let location = "at end of file";
242
243            // Report on the last line (which is blank)
244            let report_line = lines.len();
245
246            // Calculate fix: remove all trailing blank lines
247            // Find where the trailing blanks start (blank_count tells us how many consecutive blanks)
248            let fix_start = line_index
249                .get_line_start_byte(report_line - blank_count + 1)
250                .unwrap_or(0);
251            let fix_end = content.len();
252
253            // Report one warning for the excess blank lines at EOF
254            warnings.push(LintWarning {
255                rule_name: Some(self.name().to_string()),
256                severity: Severity::Warning,
257                message: format!("Multiple consecutive blank lines {location}"),
258                line: report_line,
259                column: 1,
260                end_line: report_line,
261                end_column: 1,
262                fix: Some(Fix {
263                    range: fix_start..fix_end,
264                    // The fix_start already points to the first blank line, which is AFTER
265                    // the last content line's newline. So we just remove everything from
266                    // fix_start to end, and the last content line's newline is preserved.
267                    replacement: String::new(),
268                }),
269            });
270        }
271
272        Ok(warnings)
273    }
274
275    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
276        let content = ctx.content;
277
278        let mut result = Vec::new();
279        let mut blank_count = 0;
280
281        let mut in_code_block = false;
282        let mut code_block_blanks = Vec::new();
283        let mut in_front_matter = false;
284        // Track whether the last emitted content line is heading-adjacent
285        let mut last_content_is_heading: bool = false;
286        // Track whether we've seen any content (for start-of-file detection)
287        let mut has_seen_content: bool = false;
288
289        // Process ALL lines (don't skip front-matter in fix mode)
290        for filtered_line in ctx.filtered_lines() {
291            let line = filtered_line.content;
292            let line_idx = filtered_line.line_num - 1; // Convert to 0-based
293
294            // Pass through front-matter lines unchanged
295            if filtered_line.line_info.in_front_matter {
296                if !in_front_matter {
297                    // Entering front-matter: flush any accumulated blanks
298                    let allowed_blanks = blank_count.min(self.config.maximum.get());
299                    if allowed_blanks > 0 {
300                        result.extend(vec![""; allowed_blanks]);
301                    }
302                    blank_count = 0;
303                    in_front_matter = true;
304                    last_content_is_heading = false;
305                }
306                result.push(line);
307                continue;
308            } else if in_front_matter {
309                // Exiting front-matter
310                in_front_matter = false;
311                last_content_is_heading = false;
312            }
313
314            // Track code blocks
315            if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
316                // Handle accumulated blank lines before code block
317                if !in_code_block {
318                    let heading_adjacent = last_content_is_heading;
319                    if heading_adjacent {
320                        // Preserve all blanks adjacent to headings
321                        result.extend(std::iter::repeat_n("", blank_count));
322                    } else {
323                        let allowed_blanks = blank_count.min(self.config.maximum.get());
324                        if allowed_blanks > 0 {
325                            result.extend(vec![""; allowed_blanks]);
326                        }
327                    }
328                    blank_count = 0;
329                    last_content_is_heading = false;
330                } else {
331                    // Add accumulated blank lines inside code block
332                    result.append(&mut code_block_blanks);
333                }
334                in_code_block = !in_code_block;
335                result.push(line);
336                continue;
337            }
338
339            if in_code_block {
340                if line.trim().is_empty() {
341                    code_block_blanks.push(line);
342                } else {
343                    result.append(&mut code_block_blanks);
344                    result.push(line);
345                }
346            } else if line.trim().is_empty() {
347                blank_count += 1;
348            } else {
349                // Check if blanks are between content and a heading.
350                // Start-of-file blanks before a heading are still reduced.
351                let heading_adjacent =
352                    last_content_is_heading || (has_seen_content && is_heading_context(ctx, line_idx));
353                if heading_adjacent {
354                    // Preserve all blanks adjacent to headings
355                    result.extend(std::iter::repeat_n("", blank_count));
356                } else {
357                    // Add allowed blank lines before content
358                    let allowed_blanks = blank_count.min(self.config.maximum.get());
359                    if allowed_blanks > 0 {
360                        result.extend(vec![""; allowed_blanks]);
361                    }
362                }
363                blank_count = 0;
364                last_content_is_heading = is_heading_context(ctx, line_idx);
365                has_seen_content = true;
366                result.push(line);
367            }
368        }
369
370        // Trailing blank lines at EOF are removed entirely (matching markdownlint-cli)
371
372        // Join lines and handle final newline
373        let mut output = result.join("\n");
374        if content.ends_with('\n') {
375            output.push('\n');
376        }
377
378        Ok(output)
379    }
380
381    fn as_any(&self) -> &dyn std::any::Any {
382        self
383    }
384
385    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
386        // Skip if content is empty or doesn't have newlines (single line can't have multiple blanks)
387        ctx.content.is_empty() || !ctx.has_char('\n')
388    }
389
390    fn default_config_section(&self) -> Option<(String, toml::Value)> {
391        let default_config = MD012Config::default();
392        let json_value = serde_json::to_value(&default_config).ok()?;
393        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
394
395        if let toml::Value::Table(table) = toml_value {
396            if !table.is_empty() {
397                Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
398            } else {
399                None
400            }
401        } else {
402            None
403        }
404    }
405
406    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
407    where
408        Self: Sized,
409    {
410        let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
411        Box::new(Self::from_config_struct(rule_config))
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use crate::lint_context::LintContext;
419
420    #[test]
421    fn test_single_blank_line_allowed() {
422        let rule = MD012NoMultipleBlanks::default();
423        let content = "Line 1\n\nLine 2\n\nLine 3";
424        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
425        let result = rule.check(&ctx).unwrap();
426        assert!(result.is_empty());
427    }
428
429    #[test]
430    fn test_multiple_blank_lines_flagged() {
431        let rule = MD012NoMultipleBlanks::default();
432        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
433        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
434        let result = rule.check(&ctx).unwrap();
435        assert_eq!(result.len(), 3); // 1 extra in first gap, 2 extra in second gap
436        assert_eq!(result[0].line, 3);
437        assert_eq!(result[1].line, 6);
438        assert_eq!(result[2].line, 7);
439    }
440
441    #[test]
442    fn test_custom_maximum() {
443        let rule = MD012NoMultipleBlanks::new(2);
444        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
445        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446        let result = rule.check(&ctx).unwrap();
447        assert_eq!(result.len(), 1); // Only the fourth blank line is excessive
448        assert_eq!(result[0].line, 7);
449    }
450
451    #[test]
452    fn test_fix_multiple_blank_lines() {
453        let rule = MD012NoMultipleBlanks::default();
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 fixed = rule.fix(&ctx).unwrap();
457        assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
458    }
459
460    #[test]
461    fn test_blank_lines_in_code_block() {
462        let rule = MD012NoMultipleBlanks::default();
463        let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
464        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
465        let result = rule.check(&ctx).unwrap();
466        assert!(result.is_empty()); // Blank lines inside code blocks are ignored
467    }
468
469    #[test]
470    fn test_fix_preserves_code_block_blanks() {
471        let rule = MD012NoMultipleBlanks::default();
472        let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
473        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474        let fixed = rule.fix(&ctx).unwrap();
475        assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
476    }
477
478    #[test]
479    fn test_blank_lines_in_front_matter() {
480        let rule = MD012NoMultipleBlanks::default();
481        let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
482        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
483        let result = rule.check(&ctx).unwrap();
484        assert!(result.is_empty()); // Blank lines in front matter are ignored
485    }
486
487    #[test]
488    fn test_blank_lines_at_start() {
489        let rule = MD012NoMultipleBlanks::default();
490        let content = "\n\n\nContent";
491        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
492        let result = rule.check(&ctx).unwrap();
493        assert_eq!(result.len(), 2);
494        assert!(result[0].message.contains("at start of file"));
495    }
496
497    #[test]
498    fn test_blank_lines_at_end() {
499        let rule = MD012NoMultipleBlanks::default();
500        let content = "Content\n\n\n";
501        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
502        let result = rule.check(&ctx).unwrap();
503        assert_eq!(result.len(), 1);
504        assert!(result[0].message.contains("at end of file"));
505    }
506
507    #[test]
508    fn test_single_blank_at_eof_flagged() {
509        // Markdownlint behavior: ANY blank lines at EOF are flagged
510        let rule = MD012NoMultipleBlanks::default();
511        let content = "Content\n\n";
512        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
513        let result = rule.check(&ctx).unwrap();
514        assert_eq!(result.len(), 1);
515        assert!(result[0].message.contains("at end of file"));
516    }
517
518    #[test]
519    fn test_whitespace_only_lines() {
520        let rule = MD012NoMultipleBlanks::default();
521        let content = "Line 1\n  \n\t\nLine 2";
522        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523        let result = rule.check(&ctx).unwrap();
524        assert_eq!(result.len(), 1); // Whitespace-only lines count as blank
525    }
526
527    #[test]
528    fn test_indented_code_blocks() {
529        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
530        let rule = MD012NoMultipleBlanks::default();
531        let content = "Text\n\n    code\n    \n    \n    more code\n\nText";
532        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533        let result = rule.check(&ctx).unwrap();
534        assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
535    }
536
537    #[test]
538    fn test_blanks_in_indented_code_block() {
539        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
540        let content = "    code line 1\n\n\n    code line 2\n";
541        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542        let rule = MD012NoMultipleBlanks::default();
543        let warnings = rule.check(&ctx).unwrap();
544        assert!(warnings.is_empty(), "Should not flag blanks in indented code");
545    }
546
547    #[test]
548    fn test_blanks_in_indented_code_block_with_heading() {
549        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
550        let content = "# Heading\n\n    code line 1\n\n\n    code line 2\n\nMore text\n";
551        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
552        let rule = MD012NoMultipleBlanks::default();
553        let warnings = rule.check(&ctx).unwrap();
554        assert!(
555            warnings.is_empty(),
556            "Should not flag blanks in indented code after heading"
557        );
558    }
559
560    #[test]
561    fn test_blanks_after_indented_code_block_flagged() {
562        // Blanks AFTER an indented code block end should still be flagged
563        let content = "# Heading\n\n    code line\n\n\n\nMore text\n";
564        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565        let rule = MD012NoMultipleBlanks::default();
566        let warnings = rule.check(&ctx).unwrap();
567        // There are 3 blank lines after the code block, so 2 extra should be flagged
568        assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
569    }
570
571    #[test]
572    fn test_fix_with_final_newline() {
573        let rule = MD012NoMultipleBlanks::default();
574        let content = "Line 1\n\n\nLine 2\n";
575        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
576        let fixed = rule.fix(&ctx).unwrap();
577        assert_eq!(fixed, "Line 1\n\nLine 2\n");
578        assert!(fixed.ends_with('\n'));
579    }
580
581    #[test]
582    fn test_empty_content() {
583        let rule = MD012NoMultipleBlanks::default();
584        let content = "";
585        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586        let result = rule.check(&ctx).unwrap();
587        assert!(result.is_empty());
588    }
589
590    #[test]
591    fn test_nested_code_blocks() {
592        let rule = MD012NoMultipleBlanks::default();
593        let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
594        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595        let result = rule.check(&ctx).unwrap();
596        assert!(result.is_empty());
597    }
598
599    #[test]
600    fn test_unclosed_code_block() {
601        let rule = MD012NoMultipleBlanks::default();
602        let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
603        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
604        let result = rule.check(&ctx).unwrap();
605        assert!(result.is_empty()); // Unclosed code blocks still preserve blank lines
606    }
607
608    #[test]
609    fn test_mixed_fence_styles() {
610        let rule = MD012NoMultipleBlanks::default();
611        let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
612        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613        let result = rule.check(&ctx).unwrap();
614        assert!(result.is_empty()); // Mixed fence styles should work
615    }
616
617    #[test]
618    fn test_config_from_toml() {
619        let mut config = crate::config::Config::default();
620        let mut rule_config = crate::config::RuleConfig::default();
621        rule_config
622            .values
623            .insert("maximum".to_string(), toml::Value::Integer(3));
624        config.rules.insert("MD012".to_string(), rule_config);
625
626        let rule = MD012NoMultipleBlanks::from_config(&config);
627        let content = "Line 1\n\n\n\nLine 2"; // 3 blank lines
628        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629        let result = rule.check(&ctx).unwrap();
630        assert!(result.is_empty()); // 3 blank lines allowed with maximum=3
631    }
632
633    #[test]
634    fn test_blank_lines_between_sections() {
635        // Blanks adjacent to headings are heading spacing (MD022's domain)
636        let rule = MD012NoMultipleBlanks::default();
637        let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
638        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
639        let result = rule.check(&ctx).unwrap();
640        assert!(result.is_empty(), "Blanks adjacent to headings should not be flagged");
641    }
642
643    #[test]
644    fn test_fix_preserves_indented_code() {
645        let rule = MD012NoMultipleBlanks::default();
646        let content = "Text\n\n\n    code\n    \n    more code\n\n\nText";
647        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648        let fixed = rule.fix(&ctx).unwrap();
649        // The fix removes the extra blank line, but this is expected behavior
650        assert_eq!(fixed, "Text\n\n    code\n\n    more code\n\nText");
651    }
652
653    #[test]
654    fn test_edge_case_only_blanks() {
655        let rule = MD012NoMultipleBlanks::default();
656        let content = "\n\n\n";
657        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
658        let result = rule.check(&ctx).unwrap();
659        // With the new EOF handling, we report once at EOF
660        assert_eq!(result.len(), 1);
661        assert!(result[0].message.contains("at end of file"));
662    }
663
664    // Regression tests for blanks after code blocks (GitHub issue #199 related)
665
666    #[test]
667    fn test_blanks_after_fenced_code_block_mid_document() {
668        // Blanks between code block and heading are heading-adjacent
669        let rule = MD012NoMultipleBlanks::default();
670        let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
671        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
672        let result = rule.check(&ctx).unwrap();
673        assert!(result.is_empty(), "Blanks adjacent to heading should not be flagged");
674    }
675
676    #[test]
677    fn test_blanks_after_code_block_at_eof() {
678        // Trailing blanks after code block at end of file
679        let rule = MD012NoMultipleBlanks::default();
680        let content = "# Heading\n\n```\ncode\n```\n\n\n";
681        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
682        let result = rule.check(&ctx).unwrap();
683        // Should flag the trailing blanks at EOF
684        assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
685        assert!(result[0].message.contains("at end of file"));
686    }
687
688    #[test]
689    fn test_single_blank_after_code_block_allowed() {
690        // Single blank after code block is allowed (default max=1)
691        let rule = MD012NoMultipleBlanks::default();
692        let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
693        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694        let result = rule.check(&ctx).unwrap();
695        assert!(result.is_empty(), "Single blank after code block should be allowed");
696    }
697
698    #[test]
699    fn test_multiple_code_blocks_with_blanks() {
700        // Multiple code blocks, each followed by blanks
701        let rule = MD012NoMultipleBlanks::default();
702        let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
703        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704        let result = rule.check(&ctx).unwrap();
705        // Should flag both double-blank sequences
706        assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
707    }
708
709    #[test]
710    fn test_whitespace_only_lines_after_code_block_at_eof() {
711        // Whitespace-only lines (not just empty) after code block at EOF
712        // This matches the React repo pattern where lines have trailing spaces
713        let rule = MD012NoMultipleBlanks::default();
714        let content = "```\ncode\n```\n   \n   \n";
715        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
716        let result = rule.check(&ctx).unwrap();
717        assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
718        assert!(result[0].message.contains("at end of file"));
719    }
720
721    // Tests for warning-based fix (used by LSP formatting)
722
723    #[test]
724    fn test_warning_fix_removes_single_trailing_blank() {
725        // Regression test for issue #265: LSP formatting should work for EOF blanks
726        let rule = MD012NoMultipleBlanks::default();
727        let content = "hello foobar hello.\n\n";
728        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729        let warnings = rule.check(&ctx).unwrap();
730
731        assert_eq!(warnings.len(), 1);
732        assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
733
734        let fix = warnings[0].fix.as_ref().unwrap();
735        // The fix should remove the trailing blank line
736        assert_eq!(fix.replacement, "", "Replacement should be empty");
737
738        // Apply the fix and verify result
739        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
740        assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
741    }
742
743    #[test]
744    fn test_warning_fix_removes_multiple_trailing_blanks() {
745        let rule = MD012NoMultipleBlanks::default();
746        let content = "content\n\n\n\n";
747        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
748        let warnings = rule.check(&ctx).unwrap();
749
750        assert_eq!(warnings.len(), 1);
751        assert!(warnings[0].fix.is_some());
752
753        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
754        assert_eq!(fixed, "content\n", "Should end with single newline");
755    }
756
757    #[test]
758    fn test_warning_fix_preserves_content_newline() {
759        // Ensure the fix doesn't remove the content line's trailing newline
760        let rule = MD012NoMultipleBlanks::default();
761        let content = "line1\nline2\n\n";
762        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763        let warnings = rule.check(&ctx).unwrap();
764
765        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
766        assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
767    }
768
769    #[test]
770    fn test_warning_fix_mid_document_blanks() {
771        // Blanks adjacent to headings are heading spacing (MD022's domain)
772        let rule = MD012NoMultipleBlanks::default();
773        let content = "# Heading\n\n\n\nParagraph\n";
774        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
775        let warnings = rule.check(&ctx).unwrap();
776
777        // Blanks are adjacent to a heading, so no warnings
778        assert!(warnings.is_empty(), "Blanks adjacent to heading should not be flagged");
779    }
780
781    // Heading awareness tests (issue #429)
782    // Heading spacing is MD022's domain, so MD012 skips heading-adjacent blanks
783
784    #[test]
785    fn test_heading_aware_atx_blanks_below() {
786        // Blanks below an ATX heading should not be flagged
787        let rule = MD012NoMultipleBlanks::default();
788        let content = "# Heading\n\n\nParagraph\n";
789        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790        let result = rule.check(&ctx).unwrap();
791        assert!(result.is_empty(), "Blanks below ATX heading should not be flagged");
792    }
793
794    #[test]
795    fn test_heading_aware_atx_blanks_above() {
796        // Blanks above an ATX heading should not be flagged
797        let rule = MD012NoMultipleBlanks::default();
798        let content = "Paragraph\n\n\n# Heading\n";
799        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800        let result = rule.check(&ctx).unwrap();
801        assert!(result.is_empty(), "Blanks above ATX heading should not be flagged");
802    }
803
804    #[test]
805    fn test_heading_aware_atx_blanks_between() {
806        // Blanks between two ATX headings should not be flagged
807        let rule = MD012NoMultipleBlanks::default();
808        let content = "# Heading 1\n\n\n## Heading 2\n";
809        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
810        let result = rule.check(&ctx).unwrap();
811        assert!(result.is_empty(), "Blanks between headings should not be flagged");
812    }
813
814    #[test]
815    fn test_heading_aware_setext_equals_blanks_below() {
816        // Blanks below a Setext heading (===) should not be flagged
817        let rule = MD012NoMultipleBlanks::default();
818        let content = "Heading\n=======\n\n\nParagraph\n";
819        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
820        let result = rule.check(&ctx).unwrap();
821        assert!(
822            result.is_empty(),
823            "Blanks below Setext === heading should not be flagged"
824        );
825    }
826
827    #[test]
828    fn test_heading_aware_setext_dashes_blanks_below() {
829        // Blanks below a Setext heading (---) should not be flagged
830        let rule = MD012NoMultipleBlanks::default();
831        let content = "Heading\n-------\n\n\nParagraph\n";
832        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833        let result = rule.check(&ctx).unwrap();
834        assert!(
835            result.is_empty(),
836            "Blanks below Setext --- heading should not be flagged"
837        );
838    }
839
840    #[test]
841    fn test_heading_aware_setext_blanks_above() {
842        // Blanks above a Setext heading should not be flagged
843        let rule = MD012NoMultipleBlanks::default();
844        let content = "Paragraph\n\n\nHeading\n=======\n";
845        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846        let result = rule.check(&ctx).unwrap();
847        assert!(result.is_empty(), "Blanks above Setext heading should not be flagged");
848    }
849
850    #[test]
851    fn test_heading_aware_non_heading_blanks_still_flagged() {
852        // Blanks between non-heading content should still be flagged
853        let rule = MD012NoMultipleBlanks::default();
854        let content = "Paragraph 1\n\n\nParagraph 2\n";
855        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856        let result = rule.check(&ctx).unwrap();
857        assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
858    }
859
860    #[test]
861    fn test_heading_aware_md022_coexistence() {
862        // The exact issue scenario: MD022 lines-above=2 with blanks before heading
863        let rule = MD012NoMultipleBlanks::default();
864        let content = "# Title\n\n\n## Subtitle\n\nContent\n";
865        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866        let result = rule.check(&ctx).unwrap();
867        assert!(result.is_empty(), "Should allow blanks for MD022 heading spacing");
868    }
869
870    #[test]
871    fn test_heading_aware_fix_preserves_heading_blanks() {
872        // Fix should preserve heading-adjacent blanks
873        let rule = MD012NoMultipleBlanks::default();
874        let content = "# Heading\n\n\n\nParagraph\n";
875        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876        let fixed = rule.fix(&ctx).unwrap();
877        assert_eq!(
878            fixed, "# Heading\n\n\n\nParagraph\n",
879            "Fix should preserve heading-adjacent blanks"
880        );
881    }
882
883    #[test]
884    fn test_heading_aware_fix_reduces_non_heading_blanks() {
885        // Fix should still reduce non-heading blanks
886        let rule = MD012NoMultipleBlanks::default();
887        let content = "Paragraph 1\n\n\n\nParagraph 2\n";
888        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889        let fixed = rule.fix(&ctx).unwrap();
890        assert_eq!(
891            fixed, "Paragraph 1\n\nParagraph 2\n",
892            "Fix should reduce non-heading blanks"
893        );
894    }
895
896    #[test]
897    fn test_heading_aware_mixed_heading_and_non_heading() {
898        // Document with both heading-adjacent and non-heading blanks
899        let rule = MD012NoMultipleBlanks::default();
900        let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
901        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
902        let result = rule.check(&ctx).unwrap();
903        // Only the blanks between Paragraph 1 and Paragraph 2 should be flagged
904        assert_eq!(result.len(), 1, "Should flag only non-heading blanks");
905        assert_eq!(result[0].line, 6, "Warning should be on the non-heading blank");
906    }
907
908    #[test]
909    fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
910        // Start-of-file blanks are always flagged, even before a heading.
911        // No MD022 config requires blanks at the absolute start of a file.
912        let rule = MD012NoMultipleBlanks::default();
913        let content = "\n\n\n# Heading\n";
914        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915        let result = rule.check(&ctx).unwrap();
916        assert_eq!(
917            result.len(),
918            2,
919            "Start-of-file blanks should be flagged even before heading"
920        );
921        assert!(result[0].message.contains("at start of file"));
922    }
923
924    #[test]
925    fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
926        // EOF blanks should still be flagged even after a heading
927        let rule = MD012NoMultipleBlanks::default();
928        let content = "# Heading\n\n";
929        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930        let result = rule.check(&ctx).unwrap();
931        assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
932        assert!(result[0].message.contains("at end of file"));
933    }
934
935    #[test]
936    fn test_heading_aware_custom_maximum_with_headings() {
937        // Custom maximum should not affect heading-adjacent skipping
938        let rule = MD012NoMultipleBlanks::new(2);
939        let content = "# Heading\n\n\n\n\nParagraph\n";
940        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
941        let result = rule.check(&ctx).unwrap();
942        assert!(
943            result.is_empty(),
944            "Any number of heading-adjacent blanks should be allowed"
945        );
946    }
947
948    #[test]
949    fn test_heading_aware_blanks_after_code_then_heading() {
950        // Blanks after code block followed by heading should not be flagged
951        // Tests that prev_content_line_num is reset across code blocks
952        let rule = MD012NoMultipleBlanks::default();
953        let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
954        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955        let result = rule.check(&ctx).unwrap();
956        // The blanks are between code block and "More text" (not heading-adjacent)
957        assert_eq!(result.len(), 2, "Non-heading blanks after code block should be flagged");
958    }
959
960    #[test]
961    fn test_heading_aware_fix_mixed_document() {
962        // Fix should preserve heading blanks but reduce non-heading blanks
963        let rule = MD012NoMultipleBlanks::default();
964        let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
965        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
966        let fixed = rule.fix(&ctx).unwrap();
967        // Heading-adjacent blanks preserved, non-heading blanks reduced
968        assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
969    }
970
971    // Quarto flavor tests
972
973    #[test]
974    fn test_blank_lines_in_quarto_callout() {
975        // Blank lines inside Quarto callout blocks should be allowed
976        let rule = MD012NoMultipleBlanks::default();
977        let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
978        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
979        let result = rule.check(&ctx).unwrap();
980        assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
981    }
982
983    #[test]
984    fn test_blank_lines_in_quarto_div() {
985        // Blank lines inside generic Quarto divs should be allowed
986        let rule = MD012NoMultipleBlanks::default();
987        let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
988        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
989        let result = rule.check(&ctx).unwrap();
990        assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
991    }
992
993    #[test]
994    fn test_blank_lines_outside_quarto_div_flagged() {
995        // Blank lines outside Quarto divs should still be flagged
996        let rule = MD012NoMultipleBlanks::default();
997        let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
998        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
999        let result = rule.check(&ctx).unwrap();
1000        assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1001    }
1002
1003    #[test]
1004    fn test_quarto_divs_ignored_in_standard_flavor() {
1005        // In standard flavor, Quarto div syntax is not special
1006        let rule = MD012NoMultipleBlanks::default();
1007        let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1008        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1009        let result = rule.check(&ctx).unwrap();
1010        // In standard flavor, the triple blank inside "div" is flagged
1011        assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1012    }
1013}