Skip to main content

rumdl_lib/rules/
md012_no_multiple_blanks.rs

1use crate::filtered_lines::FilteredLinesExt;
2use crate::utils::LineIndex;
3use crate::utils::range_utils::calculate_line_range;
4use std::collections::HashSet;
5use toml;
6
7use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
8use crate::rule_config_serde::RuleConfig;
9
10mod md012_config;
11use md012_config::MD012Config;
12
13/// Rule MD012: No multiple consecutive blank lines
14///
15/// See [docs/md012.md](../../docs/md012.md) for full documentation, configuration, and examples.
16
17#[derive(Debug, Clone, Default)]
18pub struct MD012NoMultipleBlanks {
19    config: MD012Config,
20}
21
22impl MD012NoMultipleBlanks {
23    pub fn new(maximum: usize) -> Self {
24        use crate::types::PositiveUsize;
25        Self {
26            config: MD012Config {
27                maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
28            },
29        }
30    }
31
32    pub const fn from_config_struct(config: MD012Config) -> Self {
33        Self { config }
34    }
35
36    /// Generate warnings for excess blank lines, handling common logic for all contexts
37    fn generate_excess_warnings(
38        &self,
39        blank_start: usize,
40        blank_count: usize,
41        lines: &[&str],
42        lines_to_check: &HashSet<usize>,
43        line_index: &LineIndex,
44    ) -> Vec<LintWarning> {
45        let mut warnings = Vec::new();
46
47        let location = if blank_start == 0 {
48            "at start of file"
49        } else {
50            "between content"
51        };
52
53        for i in self.config.maximum.get()..blank_count {
54            let excess_line_num = blank_start + i;
55            if lines_to_check.contains(&excess_line_num) {
56                let excess_line = excess_line_num + 1;
57                let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
58                let (start_line, start_col, end_line, end_col) = calculate_line_range(excess_line, excess_line_content);
59                warnings.push(LintWarning {
60                    rule_name: Some(self.name().to_string()),
61                    severity: Severity::Warning,
62                    message: format!("Multiple consecutive blank lines {location}"),
63                    line: start_line,
64                    column: start_col,
65                    end_line,
66                    end_column: end_col,
67                    fix: Some(Fix {
68                        range: {
69                            let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
70                            let line_end = line_index
71                                .get_line_start_byte(excess_line + 1)
72                                .unwrap_or(line_start + 1);
73                            line_start..line_end
74                        },
75                        replacement: String::new(),
76                    }),
77                });
78            }
79        }
80
81        warnings
82    }
83}
84
85impl Rule for MD012NoMultipleBlanks {
86    fn name(&self) -> &'static str {
87        "MD012"
88    }
89
90    fn description(&self) -> &'static str {
91        "Multiple consecutive blank lines"
92    }
93
94    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
95        let content = ctx.content;
96
97        // Early return for empty content
98        if content.is_empty() {
99            return Ok(Vec::new());
100        }
101
102        // Quick check for consecutive newlines or potential whitespace-only lines before processing
103        // Look for multiple consecutive lines that could be blank (empty or whitespace-only)
104        let lines: Vec<&str> = content.lines().collect();
105        let has_potential_blanks = lines
106            .windows(2)
107            .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
108
109        // Also check for blanks at EOF (markdownlint behavior)
110        // Content is normalized to LF at I/O boundary
111        let ends_with_multiple_newlines = content.ends_with("\n\n");
112
113        if !has_potential_blanks && !ends_with_multiple_newlines {
114            return Ok(Vec::new());
115        }
116
117        let line_index = &ctx.line_index;
118
119        let mut warnings = Vec::new();
120
121        // Single-pass algorithm with immediate counter reset
122        let mut blank_count = 0;
123        let mut blank_start = 0;
124        let mut last_line_num: Option<usize> = None;
125
126        // Use HashSet for O(1) lookups of lines that need to be checked
127        let mut lines_to_check: HashSet<usize> = HashSet::new();
128
129        // Use filtered_lines to automatically skip front-matter, code blocks, Quarto divs, and math blocks
130        // The in_code_block field in LineInfo is pre-computed using pulldown-cmark
131        // and correctly handles both fenced code blocks and indented code blocks
132        // The in_quarto_div field is only set for Quarto flavor, so skip_quarto_divs() has no effect otherwise
133        // The in_math_block field tracks $$ delimited math blocks (Quarto/LaTeX)
134        // The in_obsidian_comment field tracks Obsidian %%...%% comments (Obsidian flavor only)
135        for filtered_line in ctx
136            .filtered_lines()
137            .skip_front_matter()
138            .skip_code_blocks()
139            .skip_quarto_divs()
140            .skip_math_blocks()
141            .skip_obsidian_comments()
142        {
143            let line_num = filtered_line.line_num - 1; // Convert 1-based to 0-based for internal tracking
144            let line = filtered_line.content;
145
146            // Detect when lines were skipped (e.g., code block content)
147            // If we jump more than 1 line, there was content between, which breaks blank sequences
148            if let Some(last) = last_line_num
149                && line_num > last + 1
150            {
151                // Lines were skipped (code block or similar)
152                // Generate warnings for any accumulated blanks before the skip
153                if blank_count > self.config.maximum.get() {
154                    warnings.extend(self.generate_excess_warnings(
155                        blank_start,
156                        blank_count,
157                        &lines,
158                        &lines_to_check,
159                        line_index,
160                    ));
161                }
162                blank_count = 0;
163                lines_to_check.clear();
164            }
165            last_line_num = Some(line_num);
166
167            if line.trim().is_empty() {
168                if blank_count == 0 {
169                    blank_start = line_num;
170                }
171                blank_count += 1;
172                // Store line numbers that exceed the limit
173                if blank_count > self.config.maximum.get() {
174                    lines_to_check.insert(line_num);
175                }
176            } else {
177                if blank_count > self.config.maximum.get() {
178                    warnings.extend(self.generate_excess_warnings(
179                        blank_start,
180                        blank_count,
181                        &lines,
182                        &lines_to_check,
183                        line_index,
184                    ));
185                }
186                blank_count = 0;
187                lines_to_check.clear();
188            }
189        }
190
191        // Handle trailing blanks at EOF
192        // Main loop only reports mid-document blanks (between content)
193        // EOF handler reports trailing blanks with stricter rules (any blank at EOF is flagged)
194        //
195        // The blank_count at end of loop might include blanks BEFORE a code block at EOF,
196        // which aren't truly "trailing blanks". We need to verify the actual last line is blank.
197        let last_line_is_blank = lines.last().is_some_and(|l| l.trim().is_empty());
198
199        // Check for trailing blank lines
200        // EOF semantics: ANY blank line at EOF should be flagged (stricter than mid-document)
201        // Only fire if the actual last line(s) of the file are blank
202        if blank_count > 0 && last_line_is_blank {
203            let location = "at end of file";
204
205            // Report on the last line (which is blank)
206            let report_line = lines.len();
207
208            // Calculate fix: remove all trailing blank lines
209            // Find where the trailing blanks start (blank_count tells us how many consecutive blanks)
210            let fix_start = line_index
211                .get_line_start_byte(report_line - blank_count + 1)
212                .unwrap_or(0);
213            let fix_end = content.len();
214
215            // Report one warning for the excess blank lines at EOF
216            warnings.push(LintWarning {
217                rule_name: Some(self.name().to_string()),
218                severity: Severity::Warning,
219                message: format!("Multiple consecutive blank lines {location}"),
220                line: report_line,
221                column: 1,
222                end_line: report_line,
223                end_column: 1,
224                fix: Some(Fix {
225                    range: fix_start..fix_end,
226                    // The fix_start already points to the first blank line, which is AFTER
227                    // the last content line's newline. So we just remove everything from
228                    // fix_start to end, and the last content line's newline is preserved.
229                    replacement: String::new(),
230                }),
231            });
232        }
233
234        Ok(warnings)
235    }
236
237    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
238        let content = ctx.content;
239
240        let mut result = Vec::new();
241        let mut blank_count = 0;
242
243        let mut in_code_block = false;
244        let mut code_block_blanks = Vec::new();
245        let mut in_front_matter = false;
246
247        // Process ALL lines (don't skip front-matter in fix mode)
248        for filtered_line in ctx.filtered_lines() {
249            let line = filtered_line.content;
250
251            // Pass through front-matter lines unchanged
252            if filtered_line.line_info.in_front_matter {
253                if !in_front_matter {
254                    // Entering front-matter: flush any accumulated blanks
255                    let allowed_blanks = blank_count.min(self.config.maximum.get());
256                    if allowed_blanks > 0 {
257                        result.extend(vec![""; allowed_blanks]);
258                    }
259                    blank_count = 0;
260                    in_front_matter = true;
261                }
262                result.push(line);
263                continue;
264            } else if in_front_matter {
265                // Exiting front-matter
266                in_front_matter = false;
267            }
268
269            // Track code blocks
270            if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
271                // Handle accumulated blank lines before code block
272                if !in_code_block {
273                    let allowed_blanks = blank_count.min(self.config.maximum.get());
274                    if allowed_blanks > 0 {
275                        result.extend(vec![""; allowed_blanks]);
276                    }
277                    blank_count = 0;
278                } else {
279                    // Add accumulated blank lines inside code block
280                    result.append(&mut code_block_blanks);
281                }
282                in_code_block = !in_code_block;
283                result.push(line);
284                continue;
285            }
286
287            if in_code_block {
288                if line.trim().is_empty() {
289                    code_block_blanks.push(line);
290                } else {
291                    result.append(&mut code_block_blanks);
292                    result.push(line);
293                }
294            } else if line.trim().is_empty() {
295                blank_count += 1;
296            } else {
297                // Add allowed blank lines before content
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                result.push(line);
304            }
305        }
306
307        // Trailing blank lines at EOF are removed entirely (matching markdownlint-cli)
308
309        // Join lines and handle final newline
310        let mut output = result.join("\n");
311        if content.ends_with('\n') {
312            output.push('\n');
313        }
314
315        Ok(output)
316    }
317
318    fn as_any(&self) -> &dyn std::any::Any {
319        self
320    }
321
322    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
323        // Skip if content is empty or doesn't have newlines (single line can't have multiple blanks)
324        ctx.content.is_empty() || !ctx.has_char('\n')
325    }
326
327    fn default_config_section(&self) -> Option<(String, toml::Value)> {
328        let default_config = MD012Config::default();
329        let json_value = serde_json::to_value(&default_config).ok()?;
330        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
331
332        if let toml::Value::Table(table) = toml_value {
333            if !table.is_empty() {
334                Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
335            } else {
336                None
337            }
338        } else {
339            None
340        }
341    }
342
343    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
344    where
345        Self: Sized,
346    {
347        let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
348        Box::new(Self::from_config_struct(rule_config))
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::lint_context::LintContext;
356
357    #[test]
358    fn test_single_blank_line_allowed() {
359        let rule = MD012NoMultipleBlanks::default();
360        let content = "Line 1\n\nLine 2\n\nLine 3";
361        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
362        let result = rule.check(&ctx).unwrap();
363        assert!(result.is_empty());
364    }
365
366    #[test]
367    fn test_multiple_blank_lines_flagged() {
368        let rule = MD012NoMultipleBlanks::default();
369        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
370        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
371        let result = rule.check(&ctx).unwrap();
372        assert_eq!(result.len(), 3); // 1 extra in first gap, 2 extra in second gap
373        assert_eq!(result[0].line, 3);
374        assert_eq!(result[1].line, 6);
375        assert_eq!(result[2].line, 7);
376    }
377
378    #[test]
379    fn test_custom_maximum() {
380        let rule = MD012NoMultipleBlanks::new(2);
381        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
382        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
383        let result = rule.check(&ctx).unwrap();
384        assert_eq!(result.len(), 1); // Only the fourth blank line is excessive
385        assert_eq!(result[0].line, 7);
386    }
387
388    #[test]
389    fn test_fix_multiple_blank_lines() {
390        let rule = MD012NoMultipleBlanks::default();
391        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
392        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
393        let fixed = rule.fix(&ctx).unwrap();
394        assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
395    }
396
397    #[test]
398    fn test_blank_lines_in_code_block() {
399        let rule = MD012NoMultipleBlanks::default();
400        let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
401        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402        let result = rule.check(&ctx).unwrap();
403        assert!(result.is_empty()); // Blank lines inside code blocks are ignored
404    }
405
406    #[test]
407    fn test_fix_preserves_code_block_blanks() {
408        let rule = MD012NoMultipleBlanks::default();
409        let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
410        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
411        let fixed = rule.fix(&ctx).unwrap();
412        assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
413    }
414
415    #[test]
416    fn test_blank_lines_in_front_matter() {
417        let rule = MD012NoMultipleBlanks::default();
418        let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
419        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
420        let result = rule.check(&ctx).unwrap();
421        assert!(result.is_empty()); // Blank lines in front matter are ignored
422    }
423
424    #[test]
425    fn test_blank_lines_at_start() {
426        let rule = MD012NoMultipleBlanks::default();
427        let content = "\n\n\nContent";
428        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
429        let result = rule.check(&ctx).unwrap();
430        assert_eq!(result.len(), 2);
431        assert!(result[0].message.contains("at start of file"));
432    }
433
434    #[test]
435    fn test_blank_lines_at_end() {
436        let rule = MD012NoMultipleBlanks::default();
437        let content = "Content\n\n\n";
438        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
439        let result = rule.check(&ctx).unwrap();
440        assert_eq!(result.len(), 1);
441        assert!(result[0].message.contains("at end of file"));
442    }
443
444    #[test]
445    fn test_single_blank_at_eof_flagged() {
446        // Markdownlint behavior: ANY blank lines at EOF are flagged
447        let rule = MD012NoMultipleBlanks::default();
448        let content = "Content\n\n";
449        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
450        let result = rule.check(&ctx).unwrap();
451        assert_eq!(result.len(), 1);
452        assert!(result[0].message.contains("at end of file"));
453    }
454
455    #[test]
456    fn test_whitespace_only_lines() {
457        let rule = MD012NoMultipleBlanks::default();
458        let content = "Line 1\n  \n\t\nLine 2";
459        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460        let result = rule.check(&ctx).unwrap();
461        assert_eq!(result.len(), 1); // Whitespace-only lines count as blank
462    }
463
464    #[test]
465    fn test_indented_code_blocks() {
466        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
467        let rule = MD012NoMultipleBlanks::default();
468        let content = "Text\n\n    code\n    \n    \n    more code\n\nText";
469        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
470        let result = rule.check(&ctx).unwrap();
471        assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
472    }
473
474    #[test]
475    fn test_blanks_in_indented_code_block() {
476        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
477        let content = "    code line 1\n\n\n    code line 2\n";
478        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479        let rule = MD012NoMultipleBlanks::default();
480        let warnings = rule.check(&ctx).unwrap();
481        assert!(warnings.is_empty(), "Should not flag blanks in indented code");
482    }
483
484    #[test]
485    fn test_blanks_in_indented_code_block_with_heading() {
486        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
487        let content = "# Heading\n\n    code line 1\n\n\n    code line 2\n\nMore text\n";
488        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
489        let rule = MD012NoMultipleBlanks::default();
490        let warnings = rule.check(&ctx).unwrap();
491        assert!(
492            warnings.is_empty(),
493            "Should not flag blanks in indented code after heading"
494        );
495    }
496
497    #[test]
498    fn test_blanks_after_indented_code_block_flagged() {
499        // Blanks AFTER an indented code block end should still be flagged
500        let content = "# Heading\n\n    code line\n\n\n\nMore text\n";
501        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
502        let rule = MD012NoMultipleBlanks::default();
503        let warnings = rule.check(&ctx).unwrap();
504        // There are 3 blank lines after the code block, so 2 extra should be flagged
505        assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
506    }
507
508    #[test]
509    fn test_fix_with_final_newline() {
510        let rule = MD012NoMultipleBlanks::default();
511        let content = "Line 1\n\n\nLine 2\n";
512        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
513        let fixed = rule.fix(&ctx).unwrap();
514        assert_eq!(fixed, "Line 1\n\nLine 2\n");
515        assert!(fixed.ends_with('\n'));
516    }
517
518    #[test]
519    fn test_empty_content() {
520        let rule = MD012NoMultipleBlanks::default();
521        let content = "";
522        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523        let result = rule.check(&ctx).unwrap();
524        assert!(result.is_empty());
525    }
526
527    #[test]
528    fn test_nested_code_blocks() {
529        let rule = MD012NoMultipleBlanks::default();
530        let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
531        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532        let result = rule.check(&ctx).unwrap();
533        assert!(result.is_empty());
534    }
535
536    #[test]
537    fn test_unclosed_code_block() {
538        let rule = MD012NoMultipleBlanks::default();
539        let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
540        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
541        let result = rule.check(&ctx).unwrap();
542        assert!(result.is_empty()); // Unclosed code blocks still preserve blank lines
543    }
544
545    #[test]
546    fn test_mixed_fence_styles() {
547        let rule = MD012NoMultipleBlanks::default();
548        let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
549        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
550        let result = rule.check(&ctx).unwrap();
551        assert!(result.is_empty()); // Mixed fence styles should work
552    }
553
554    #[test]
555    fn test_config_from_toml() {
556        let mut config = crate::config::Config::default();
557        let mut rule_config = crate::config::RuleConfig::default();
558        rule_config
559            .values
560            .insert("maximum".to_string(), toml::Value::Integer(3));
561        config.rules.insert("MD012".to_string(), rule_config);
562
563        let rule = MD012NoMultipleBlanks::from_config(&config);
564        let content = "Line 1\n\n\n\nLine 2"; // 3 blank lines
565        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
566        let result = rule.check(&ctx).unwrap();
567        assert!(result.is_empty()); // 3 blank lines allowed with maximum=3
568    }
569
570    #[test]
571    fn test_blank_lines_between_sections() {
572        let rule = MD012NoMultipleBlanks::default();
573        let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
574        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
575        let result = rule.check(&ctx).unwrap();
576        assert_eq!(result.len(), 1);
577        assert_eq!(result[0].line, 5);
578    }
579
580    #[test]
581    fn test_fix_preserves_indented_code() {
582        let rule = MD012NoMultipleBlanks::default();
583        let content = "Text\n\n\n    code\n    \n    more code\n\n\nText";
584        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585        let fixed = rule.fix(&ctx).unwrap();
586        // The fix removes the extra blank line, but this is expected behavior
587        assert_eq!(fixed, "Text\n\n    code\n\n    more code\n\nText");
588    }
589
590    #[test]
591    fn test_edge_case_only_blanks() {
592        let rule = MD012NoMultipleBlanks::default();
593        let content = "\n\n\n";
594        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595        let result = rule.check(&ctx).unwrap();
596        // With the new EOF handling, we report once at EOF
597        assert_eq!(result.len(), 1);
598        assert!(result[0].message.contains("at end of file"));
599    }
600
601    // Regression tests for blanks after code blocks (GitHub issue #199 related)
602
603    #[test]
604    fn test_blanks_after_fenced_code_block_mid_document() {
605        // This is the pattern from React repo test files that was being missed
606        let rule = MD012NoMultipleBlanks::default();
607        let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
608        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
609        let result = rule.check(&ctx).unwrap();
610        // Should flag the double blank between code block and next heading
611        assert_eq!(result.len(), 1, "Should detect blanks after code block");
612        assert_eq!(result[0].line, 7, "Warning should be on line 7 (second blank)");
613        assert!(result[0].message.contains("between content"));
614    }
615
616    #[test]
617    fn test_blanks_after_code_block_at_eof() {
618        // Trailing blanks after code block at end of file
619        let rule = MD012NoMultipleBlanks::default();
620        let content = "# Heading\n\n```\ncode\n```\n\n\n";
621        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
622        let result = rule.check(&ctx).unwrap();
623        // Should flag the trailing blanks at EOF
624        assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
625        assert!(result[0].message.contains("at end of file"));
626    }
627
628    #[test]
629    fn test_single_blank_after_code_block_allowed() {
630        // Single blank after code block is allowed (default max=1)
631        let rule = MD012NoMultipleBlanks::default();
632        let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
633        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
634        let result = rule.check(&ctx).unwrap();
635        assert!(result.is_empty(), "Single blank after code block should be allowed");
636    }
637
638    #[test]
639    fn test_multiple_code_blocks_with_blanks() {
640        // Multiple code blocks, each followed by blanks
641        let rule = MD012NoMultipleBlanks::default();
642        let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
643        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
644        let result = rule.check(&ctx).unwrap();
645        // Should flag both double-blank sequences
646        assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
647    }
648
649    #[test]
650    fn test_whitespace_only_lines_after_code_block_at_eof() {
651        // Whitespace-only lines (not just empty) after code block at EOF
652        // This matches the React repo pattern where lines have trailing spaces
653        let rule = MD012NoMultipleBlanks::default();
654        let content = "```\ncode\n```\n   \n   \n";
655        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
656        let result = rule.check(&ctx).unwrap();
657        assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
658        assert!(result[0].message.contains("at end of file"));
659    }
660
661    // Tests for warning-based fix (used by LSP formatting)
662
663    #[test]
664    fn test_warning_fix_removes_single_trailing_blank() {
665        // Regression test for issue #265: LSP formatting should work for EOF blanks
666        let rule = MD012NoMultipleBlanks::default();
667        let content = "hello foobar hello.\n\n";
668        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669        let warnings = rule.check(&ctx).unwrap();
670
671        assert_eq!(warnings.len(), 1);
672        assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
673
674        let fix = warnings[0].fix.as_ref().unwrap();
675        // The fix should remove the trailing blank line
676        assert_eq!(fix.replacement, "", "Replacement should be empty");
677
678        // Apply the fix and verify result
679        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
680        assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
681    }
682
683    #[test]
684    fn test_warning_fix_removes_multiple_trailing_blanks() {
685        let rule = MD012NoMultipleBlanks::default();
686        let content = "content\n\n\n\n";
687        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
688        let warnings = rule.check(&ctx).unwrap();
689
690        assert_eq!(warnings.len(), 1);
691        assert!(warnings[0].fix.is_some());
692
693        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
694        assert_eq!(fixed, "content\n", "Should end with single newline");
695    }
696
697    #[test]
698    fn test_warning_fix_preserves_content_newline() {
699        // Ensure the fix doesn't remove the content line's trailing newline
700        let rule = MD012NoMultipleBlanks::default();
701        let content = "line1\nline2\n\n";
702        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703        let warnings = rule.check(&ctx).unwrap();
704
705        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
706        assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
707    }
708
709    #[test]
710    fn test_warning_fix_mid_document_blanks() {
711        // Test that mid-document blank line fixes also work via warnings
712        let rule = MD012NoMultipleBlanks::default();
713        // Content with 2 extra blank lines (3 blank lines total, should reduce to 1)
714        let content = "# Heading\n\n\n\nParagraph\n";
715        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
716        let warnings = rule.check(&ctx).unwrap();
717
718        // With maximum=1 (default), 3 consecutive blanks produces 2 warnings
719        assert_eq!(warnings.len(), 2, "Should have 2 warnings for 2 extra blank lines");
720        assert!(warnings[0].fix.is_some());
721        assert!(warnings[1].fix.is_some());
722
723        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
724        assert_eq!(fixed, "# Heading\n\nParagraph\n", "Should reduce to single blank");
725    }
726
727    // Quarto flavor tests
728
729    #[test]
730    fn test_blank_lines_in_quarto_callout() {
731        // Blank lines inside Quarto callout blocks should be allowed
732        let rule = MD012NoMultipleBlanks::default();
733        let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
734        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
735        let result = rule.check(&ctx).unwrap();
736        assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
737    }
738
739    #[test]
740    fn test_blank_lines_in_quarto_div() {
741        // Blank lines inside generic Quarto divs should be allowed
742        let rule = MD012NoMultipleBlanks::default();
743        let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
744        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
745        let result = rule.check(&ctx).unwrap();
746        assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
747    }
748
749    #[test]
750    fn test_blank_lines_outside_quarto_div_flagged() {
751        // Blank lines outside Quarto divs should still be flagged
752        let rule = MD012NoMultipleBlanks::default();
753        let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
754        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
755        let result = rule.check(&ctx).unwrap();
756        assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
757    }
758
759    #[test]
760    fn test_quarto_divs_ignored_in_standard_flavor() {
761        // In standard flavor, Quarto div syntax is not special
762        let rule = MD012NoMultipleBlanks::default();
763        let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
764        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
765        let result = rule.check(&ctx).unwrap();
766        // In standard flavor, the triple blank inside "div" is flagged
767        assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
768    }
769}