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