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