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
125        // Use HashSet for O(1) lookups of lines that need to be checked
126        let mut lines_to_check: HashSet<usize> = HashSet::new();
127
128        // Use filtered_lines to automatically skip front-matter and code blocks
129        // The in_code_block field in LineInfo is pre-computed using pulldown-cmark
130        // and correctly handles both fenced code blocks and indented code blocks
131        for filtered_line in ctx.filtered_lines().skip_front_matter().skip_code_blocks() {
132            let line_num = filtered_line.line_num - 1; // Convert 1-based to 0-based for internal tracking
133            let line = filtered_line.content;
134
135            if line.trim().is_empty() {
136                if blank_count == 0 {
137                    blank_start = line_num;
138                }
139                blank_count += 1;
140                // Store line numbers that exceed the limit
141                if blank_count > self.config.maximum.get() {
142                    lines_to_check.insert(line_num);
143                }
144            } else {
145                if blank_count > self.config.maximum.get() {
146                    warnings.extend(self.generate_excess_warnings(
147                        blank_start,
148                        blank_count,
149                        &lines,
150                        &lines_to_check,
151                        line_index,
152                    ));
153                }
154                blank_count = 0;
155                lines_to_check.clear();
156            }
157        }
158
159        // Check for trailing blank lines
160        // Special handling: lines() doesn't create an empty string for a final trailing newline
161        // So we need to check the raw content for multiple trailing newlines
162
163        // Count consecutive newlines at the end of the file
164        let mut consecutive_newlines_at_end: usize = 0;
165        for ch in content.chars().rev() {
166            if ch == '\n' {
167                consecutive_newlines_at_end += 1;
168            } else if ch == '\r' {
169                // Skip carriage returns in CRLF
170                continue;
171            } else {
172                break;
173            }
174        }
175
176        // To have N blank lines at EOF, you need N+1 trailing newlines
177        // For example: "content\n\n" has 1 blank line (2 newlines)
178        let blank_lines_at_eof = consecutive_newlines_at_end.saturating_sub(1);
179
180        // At EOF, blank lines are always enforced to be 0 (POSIX/Prettier standard)
181        // The `maximum` config only applies to in-document blank lines
182        if blank_lines_at_eof > 0 {
183            let location = "at end of file";
184
185            // Report on the last line (which is blank)
186            let report_line = lines.len();
187
188            // Calculate how many newlines to remove
189            // Always keep exactly 1 newline at EOF (0 blank lines)
190            let target_newlines = 1;
191            let excess_newlines = consecutive_newlines_at_end - target_newlines;
192
193            // Report one warning for the excess blank lines at EOF
194            warnings.push(LintWarning {
195                rule_name: Some(self.name().to_string()),
196                severity: Severity::Warning,
197                message: format!("Multiple consecutive blank lines {location}"),
198                line: report_line,
199                column: 1,
200                end_line: report_line,
201                end_column: 1,
202                fix: Some(Fix {
203                    range: {
204                        // Remove excess trailing newlines
205                        let keep_chars = content.len() - excess_newlines;
206                        log::debug!(
207                            "MD012 EOF: consecutive_newlines_at_end={}, blank_lines_at_eof={}, target_newlines={}, excess_newlines={}, content_len={}, keep_chars={}, range={}..{}",
208                            consecutive_newlines_at_end,
209                            blank_lines_at_eof,
210                            target_newlines,
211                            excess_newlines,
212                            content.len(),
213                            keep_chars,
214                            keep_chars,
215                            content.len()
216                        );
217                        keep_chars..content.len()
218                    },
219                    replacement: String::new(),
220                }),
221            });
222        }
223
224        Ok(warnings)
225    }
226
227    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
228        let content = ctx.content;
229
230        let mut result = Vec::new();
231        let mut blank_count = 0;
232
233        let mut in_code_block = false;
234        let mut code_block_blanks = Vec::new();
235        let mut in_front_matter = false;
236
237        // Process ALL lines (don't skip front-matter in fix mode)
238        for filtered_line in ctx.filtered_lines() {
239            let line = filtered_line.content;
240
241            // Pass through front-matter lines unchanged
242            if filtered_line.line_info.in_front_matter {
243                if !in_front_matter {
244                    // Entering front-matter: flush any accumulated blanks
245                    let allowed_blanks = blank_count.min(self.config.maximum.get());
246                    if allowed_blanks > 0 {
247                        result.extend(vec![""; allowed_blanks]);
248                    }
249                    blank_count = 0;
250                    in_front_matter = true;
251                }
252                result.push(line);
253                continue;
254            } else if in_front_matter {
255                // Exiting front-matter
256                in_front_matter = false;
257            }
258
259            // Track code blocks
260            if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
261                // Handle accumulated blank lines before code block
262                if !in_code_block {
263                    let allowed_blanks = blank_count.min(self.config.maximum.get());
264                    if allowed_blanks > 0 {
265                        result.extend(vec![""; allowed_blanks]);
266                    }
267                    blank_count = 0;
268                } else {
269                    // Add accumulated blank lines inside code block
270                    result.append(&mut code_block_blanks);
271                }
272                in_code_block = !in_code_block;
273                result.push(line);
274                continue;
275            }
276
277            if in_code_block {
278                if line.trim().is_empty() {
279                    code_block_blanks.push(line);
280                } else {
281                    result.append(&mut code_block_blanks);
282                    result.push(line);
283                }
284            } else if line.trim().is_empty() {
285                blank_count += 1;
286            } else {
287                // Add allowed blank lines before content
288                let allowed_blanks = blank_count.min(self.config.maximum.get());
289                if allowed_blanks > 0 {
290                    result.extend(vec![""; allowed_blanks]);
291                }
292                blank_count = 0;
293                result.push(line);
294            }
295        }
296
297        // Handle trailing blank lines
298        // After the loop, blank_count contains the number of trailing blank lines
299        // Add up to maximum allowed trailing blank lines
300        let allowed_trailing_blanks = blank_count.min(self.config.maximum.get());
301        if allowed_trailing_blanks > 0 {
302            result.extend(vec![""; allowed_trailing_blanks]);
303        }
304
305        // Join lines and handle final newline
306        let mut output = result.join("\n");
307        if content.ends_with('\n') {
308            output.push('\n');
309        }
310
311        Ok(output)
312    }
313
314    fn as_any(&self) -> &dyn std::any::Any {
315        self
316    }
317
318    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
319        // Skip if content is empty or doesn't have newlines (single line can't have multiple blanks)
320        ctx.content.is_empty() || !ctx.has_char('\n')
321    }
322
323    fn default_config_section(&self) -> Option<(String, toml::Value)> {
324        let default_config = MD012Config::default();
325        let json_value = serde_json::to_value(&default_config).ok()?;
326        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
327
328        if let toml::Value::Table(table) = toml_value {
329            if !table.is_empty() {
330                Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
331            } else {
332                None
333            }
334        } else {
335            None
336        }
337    }
338
339    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
340    where
341        Self: Sized,
342    {
343        let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
344        Box::new(Self::from_config_struct(rule_config))
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::lint_context::LintContext;
352
353    #[test]
354    fn test_single_blank_line_allowed() {
355        let rule = MD012NoMultipleBlanks::default();
356        let content = "Line 1\n\nLine 2\n\nLine 3";
357        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
358        let result = rule.check(&ctx).unwrap();
359        assert!(result.is_empty());
360    }
361
362    #[test]
363    fn test_multiple_blank_lines_flagged() {
364        let rule = MD012NoMultipleBlanks::default();
365        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
366        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
367        let result = rule.check(&ctx).unwrap();
368        assert_eq!(result.len(), 3); // 1 extra in first gap, 2 extra in second gap
369        assert_eq!(result[0].line, 3);
370        assert_eq!(result[1].line, 6);
371        assert_eq!(result[2].line, 7);
372    }
373
374    #[test]
375    fn test_custom_maximum() {
376        let rule = MD012NoMultipleBlanks::new(2);
377        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
378        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
379        let result = rule.check(&ctx).unwrap();
380        assert_eq!(result.len(), 1); // Only the fourth blank line is excessive
381        assert_eq!(result[0].line, 7);
382    }
383
384    #[test]
385    fn test_fix_multiple_blank_lines() {
386        let rule = MD012NoMultipleBlanks::default();
387        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
388        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
389        let fixed = rule.fix(&ctx).unwrap();
390        assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
391    }
392
393    #[test]
394    fn test_blank_lines_in_code_block() {
395        let rule = MD012NoMultipleBlanks::default();
396        let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
397        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398        let result = rule.check(&ctx).unwrap();
399        assert!(result.is_empty()); // Blank lines inside code blocks are ignored
400    }
401
402    #[test]
403    fn test_fix_preserves_code_block_blanks() {
404        let rule = MD012NoMultipleBlanks::default();
405        let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
406        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
407        let fixed = rule.fix(&ctx).unwrap();
408        assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
409    }
410
411    #[test]
412    fn test_blank_lines_in_front_matter() {
413        let rule = MD012NoMultipleBlanks::default();
414        let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
415        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
416        let result = rule.check(&ctx).unwrap();
417        assert!(result.is_empty()); // Blank lines in front matter are ignored
418    }
419
420    #[test]
421    fn test_blank_lines_at_start() {
422        let rule = MD012NoMultipleBlanks::default();
423        let content = "\n\n\nContent";
424        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
425        let result = rule.check(&ctx).unwrap();
426        assert_eq!(result.len(), 2);
427        assert!(result[0].message.contains("at start of file"));
428    }
429
430    #[test]
431    fn test_blank_lines_at_end() {
432        let rule = MD012NoMultipleBlanks::default();
433        let content = "Content\n\n\n";
434        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
435        let result = rule.check(&ctx).unwrap();
436        assert_eq!(result.len(), 1);
437        assert!(result[0].message.contains("at end of file"));
438    }
439
440    #[test]
441    fn test_single_blank_at_eof_flagged() {
442        // Markdownlint behavior: ANY blank lines at EOF are flagged
443        let rule = MD012NoMultipleBlanks::default();
444        let content = "Content\n\n";
445        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446        let result = rule.check(&ctx).unwrap();
447        assert_eq!(result.len(), 1);
448        assert!(result[0].message.contains("at end of file"));
449    }
450
451    #[test]
452    fn test_whitespace_only_lines() {
453        let rule = MD012NoMultipleBlanks::default();
454        let content = "Line 1\n  \n\t\nLine 2";
455        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456        let result = rule.check(&ctx).unwrap();
457        assert_eq!(result.len(), 1); // Whitespace-only lines count as blank
458    }
459
460    #[test]
461    fn test_indented_code_blocks() {
462        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
463        let rule = MD012NoMultipleBlanks::default();
464        let content = "Text\n\n    code\n    \n    \n    more code\n\nText";
465        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
466        let result = rule.check(&ctx).unwrap();
467        assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
468    }
469
470    #[test]
471    fn test_blanks_in_indented_code_block() {
472        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
473        let content = "    code line 1\n\n\n    code line 2\n";
474        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
475        let rule = MD012NoMultipleBlanks::default();
476        let warnings = rule.check(&ctx).unwrap();
477        assert!(warnings.is_empty(), "Should not flag blanks in indented code");
478    }
479
480    #[test]
481    fn test_blanks_in_indented_code_block_with_heading() {
482        // Per markdownlint-cli reference: blank lines inside indented code blocks are valid
483        let content = "# Heading\n\n    code line 1\n\n\n    code line 2\n\nMore text\n";
484        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485        let rule = MD012NoMultipleBlanks::default();
486        let warnings = rule.check(&ctx).unwrap();
487        assert!(
488            warnings.is_empty(),
489            "Should not flag blanks in indented code after heading"
490        );
491    }
492
493    #[test]
494    fn test_blanks_after_indented_code_block_flagged() {
495        // Blanks AFTER an indented code block end should still be flagged
496        let content = "# Heading\n\n    code line\n\n\n\nMore text\n";
497        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
498        let rule = MD012NoMultipleBlanks::default();
499        let warnings = rule.check(&ctx).unwrap();
500        // There are 3 blank lines after the code block, so 2 extra should be flagged
501        assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
502    }
503
504    #[test]
505    fn test_fix_with_final_newline() {
506        let rule = MD012NoMultipleBlanks::default();
507        let content = "Line 1\n\n\nLine 2\n";
508        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509        let fixed = rule.fix(&ctx).unwrap();
510        assert_eq!(fixed, "Line 1\n\nLine 2\n");
511        assert!(fixed.ends_with('\n'));
512    }
513
514    #[test]
515    fn test_empty_content() {
516        let rule = MD012NoMultipleBlanks::default();
517        let content = "";
518        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
519        let result = rule.check(&ctx).unwrap();
520        assert!(result.is_empty());
521    }
522
523    #[test]
524    fn test_nested_code_blocks() {
525        let rule = MD012NoMultipleBlanks::default();
526        let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
527        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
528        let result = rule.check(&ctx).unwrap();
529        assert!(result.is_empty());
530    }
531
532    #[test]
533    fn test_unclosed_code_block() {
534        let rule = MD012NoMultipleBlanks::default();
535        let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
536        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
537        let result = rule.check(&ctx).unwrap();
538        assert!(result.is_empty()); // Unclosed code blocks still preserve blank lines
539    }
540
541    #[test]
542    fn test_mixed_fence_styles() {
543        let rule = MD012NoMultipleBlanks::default();
544        let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
545        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
546        let result = rule.check(&ctx).unwrap();
547        assert!(result.is_empty()); // Mixed fence styles should work
548    }
549
550    #[test]
551    fn test_config_from_toml() {
552        let mut config = crate::config::Config::default();
553        let mut rule_config = crate::config::RuleConfig::default();
554        rule_config
555            .values
556            .insert("maximum".to_string(), toml::Value::Integer(3));
557        config.rules.insert("MD012".to_string(), rule_config);
558
559        let rule = MD012NoMultipleBlanks::from_config(&config);
560        let content = "Line 1\n\n\n\nLine 2"; // 3 blank lines
561        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562        let result = rule.check(&ctx).unwrap();
563        assert!(result.is_empty()); // 3 blank lines allowed with maximum=3
564    }
565
566    #[test]
567    fn test_blank_lines_between_sections() {
568        let rule = MD012NoMultipleBlanks::default();
569        let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
570        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
571        let result = rule.check(&ctx).unwrap();
572        assert_eq!(result.len(), 1);
573        assert_eq!(result[0].line, 5);
574    }
575
576    #[test]
577    fn test_fix_preserves_indented_code() {
578        let rule = MD012NoMultipleBlanks::default();
579        let content = "Text\n\n\n    code\n    \n    more code\n\n\nText";
580        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581        let fixed = rule.fix(&ctx).unwrap();
582        // The fix removes the extra blank line, but this is expected behavior
583        assert_eq!(fixed, "Text\n\n    code\n\n    more code\n\nText");
584    }
585
586    #[test]
587    fn test_edge_case_only_blanks() {
588        let rule = MD012NoMultipleBlanks::default();
589        let content = "\n\n\n";
590        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591        let result = rule.check(&ctx).unwrap();
592        // With the new EOF handling, we report once at EOF
593        assert_eq!(result.len(), 1);
594        assert!(result[0].message.contains("at end of file"));
595    }
596}