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