rumdl_lib/rules/
md012_no_multiple_blanks.rs

1use crate::filtered_lines::FilteredLinesExt;
2use crate::utils::range_utils::calculate_line_range;
3use std::collections::HashSet;
4use toml;
5
6use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
7use crate::rule_config_serde::RuleConfig;
8
9mod md012_config;
10use md012_config::MD012Config;
11
12/// Rule MD012: No multiple consecutive blank lines
13///
14/// See [docs/md012.md](../../docs/md012.md) for full documentation, configuration, and examples.
15
16#[derive(Debug, Clone, Default)]
17pub struct MD012NoMultipleBlanks {
18    config: MD012Config,
19}
20
21impl MD012NoMultipleBlanks {
22    pub fn new(maximum: usize) -> Self {
23        use crate::types::PositiveUsize;
24        Self {
25            config: MD012Config {
26                maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
27            },
28        }
29    }
30
31    pub const fn from_config_struct(config: MD012Config) -> Self {
32        Self { config }
33    }
34}
35
36impl Rule for MD012NoMultipleBlanks {
37    fn name(&self) -> &'static str {
38        "MD012"
39    }
40
41    fn description(&self) -> &'static str {
42        "Multiple consecutive blank lines"
43    }
44
45    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
46        let content = ctx.content;
47
48        // Early return for empty content
49        if content.is_empty() {
50            return Ok(Vec::new());
51        }
52
53        // Quick check for consecutive newlines or potential whitespace-only lines before processing
54        // Look for multiple consecutive lines that could be blank (empty or whitespace-only)
55        let lines: Vec<&str> = content.lines().collect();
56        let has_potential_blanks = lines
57            .windows(2)
58            .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
59
60        // Also check for blanks at EOF (markdownlint behavior)
61        // Content is normalized to LF at I/O boundary
62        let ends_with_multiple_newlines = content.ends_with("\n\n");
63
64        if !has_potential_blanks && !ends_with_multiple_newlines {
65            return Ok(Vec::new());
66        }
67
68        let line_index = &ctx.line_index;
69
70        let mut warnings = Vec::new();
71
72        // Single-pass algorithm with immediate counter reset
73        let mut blank_count = 0;
74        let mut blank_start = 0;
75        let mut in_code_block = false;
76        let mut code_fence_marker = "";
77
78        // Use HashSet for O(1) lookups of lines that need to be checked
79        let mut lines_to_check: HashSet<usize> = HashSet::new();
80
81        // Use filtered_lines to automatically skip front-matter lines
82        for filtered_line in ctx.filtered_lines().skip_front_matter() {
83            let line_num = filtered_line.line_num - 1; // Convert 1-based to 0-based for internal tracking
84            let line = filtered_line.content;
85            let trimmed = line.trim_start();
86
87            // Check for code block boundaries
88            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
89                // Check for excess blanks before entering/exiting code block
90                if blank_count > self.config.maximum.get() {
91                    let location = if blank_start == 0 {
92                        "at start of file"
93                    } else {
94                        "between content"
95                    };
96                    for i in self.config.maximum.get()..blank_count {
97                        let excess_line_num = blank_start + i;
98                        if lines_to_check.contains(&excess_line_num) {
99                            let excess_line = excess_line_num + 1;
100                            let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
101                            let (start_line, start_col, end_line, end_col) =
102                                calculate_line_range(excess_line, excess_line_content);
103                            warnings.push(LintWarning {
104                                rule_name: Some(self.name().to_string()),
105                                severity: Severity::Warning,
106                                message: format!("Multiple consecutive blank lines {location}"),
107                                line: start_line,
108                                column: start_col,
109                                end_line,
110                                end_column: end_col,
111                                fix: Some(Fix {
112                                    range: {
113                                        let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
114                                        let line_end = line_index
115                                            .get_line_start_byte(excess_line + 1)
116                                            .unwrap_or(line_start + 1);
117                                        line_start..line_end
118                                    },
119                                    replacement: String::new(),
120                                }),
121                            });
122                        }
123                    }
124                }
125
126                if !in_code_block {
127                    // Entering code block
128                    in_code_block = true;
129                    code_fence_marker = if trimmed.starts_with("```") { "```" } else { "~~~" };
130                } else if trimmed.starts_with(code_fence_marker) {
131                    // Exiting code block
132                    in_code_block = false;
133                    code_fence_marker = "";
134                }
135                blank_count = 0;
136                lines_to_check.clear();
137                continue;
138            }
139
140            // Skip lines in code blocks
141            if in_code_block {
142                // Reset counter to prevent counting across boundaries
143                blank_count = 0;
144                continue;
145            }
146
147            // Check for indented code blocks (4+ spaces)
148            let is_indented_code = line.len() >= 4 && line.starts_with("    ") && !line.trim().is_empty();
149            if is_indented_code {
150                // Check for excess blanks before indented code block
151                if blank_count > self.config.maximum.get() {
152                    let location = if blank_start == 0 {
153                        "at start of file"
154                    } else {
155                        "between content"
156                    };
157                    for i in self.config.maximum.get()..blank_count {
158                        let excess_line_num = blank_start + i;
159                        if lines_to_check.contains(&excess_line_num) {
160                            let excess_line = excess_line_num + 1;
161                            let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
162                            let (start_line, start_col, end_line, end_col) =
163                                calculate_line_range(excess_line, excess_line_content);
164                            warnings.push(LintWarning {
165                                rule_name: Some(self.name().to_string()),
166                                severity: Severity::Warning,
167                                message: format!("Multiple consecutive blank lines {location}"),
168                                line: start_line,
169                                column: start_col,
170                                end_line,
171                                end_column: end_col,
172                                fix: Some(Fix {
173                                    range: {
174                                        let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
175                                        let line_end = line_index
176                                            .get_line_start_byte(excess_line + 1)
177                                            .unwrap_or(line_start + 1);
178                                        line_start..line_end
179                                    },
180                                    replacement: String::new(),
181                                }),
182                            });
183                        }
184                    }
185                }
186                blank_count = 0;
187                lines_to_check.clear();
188                continue;
189            }
190
191            if line.trim().is_empty() {
192                if blank_count == 0 {
193                    blank_start = line_num;
194                }
195                blank_count += 1;
196                // Store line numbers that exceed the limit
197                if blank_count > self.config.maximum.get() {
198                    lines_to_check.insert(line_num);
199                }
200            } else {
201                if blank_count > self.config.maximum.get() {
202                    // Generate warnings for each excess blank line
203                    let location = if blank_start == 0 {
204                        "at start of file"
205                    } else {
206                        "between content"
207                    };
208
209                    // Report warnings starting from the (maximum+1)th blank line
210                    for i in self.config.maximum.get()..blank_count {
211                        let excess_line_num = blank_start + i;
212                        if lines_to_check.contains(&excess_line_num) {
213                            let excess_line = excess_line_num + 1; // +1 for 1-indexed lines
214                            let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
215
216                            // Calculate precise character range for the entire blank line
217                            let (start_line, start_col, end_line, end_col) =
218                                calculate_line_range(excess_line, excess_line_content);
219
220                            warnings.push(LintWarning {
221                                rule_name: Some(self.name().to_string()),
222                                severity: Severity::Warning,
223                                message: format!("Multiple consecutive blank lines {location}"),
224                                line: start_line,
225                                column: start_col,
226                                end_line,
227                                end_column: end_col,
228                                fix: Some(Fix {
229                                    range: {
230                                        // Remove entire line including newline
231                                        let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
232                                        let line_end = line_index
233                                            .get_line_start_byte(excess_line + 1)
234                                            .unwrap_or(line_start + 1);
235                                        line_start..line_end
236                                    },
237                                    replacement: String::new(), // Remove the excess line
238                                }),
239                            });
240                        }
241                    }
242                }
243                blank_count = 0;
244                lines_to_check.clear();
245            }
246        }
247
248        // Check for trailing blank lines
249        // Special handling: lines() doesn't create an empty string for a final trailing newline
250        // So we need to check the raw content for multiple trailing newlines
251
252        // Count consecutive newlines at the end of the file
253        let mut consecutive_newlines_at_end: usize = 0;
254        for ch in content.chars().rev() {
255            if ch == '\n' {
256                consecutive_newlines_at_end += 1;
257            } else if ch == '\r' {
258                // Skip carriage returns in CRLF
259                continue;
260            } else {
261                break;
262            }
263        }
264
265        // To have N blank lines at EOF, you need N+1 trailing newlines
266        // For example: "content\n\n" has 1 blank line (2 newlines)
267        let blank_lines_at_eof = consecutive_newlines_at_end.saturating_sub(1);
268
269        // At EOF, blank lines are always enforced to be 0 (POSIX/Prettier standard)
270        // The `maximum` config only applies to in-document blank lines
271        if blank_lines_at_eof > 0 {
272            let location = "at end of file";
273
274            // Report on the last line (which is blank)
275            let report_line = lines.len();
276
277            // Calculate how many newlines to remove
278            // Always keep exactly 1 newline at EOF (0 blank lines)
279            let target_newlines = 1;
280            let excess_newlines = consecutive_newlines_at_end - target_newlines;
281
282            // Report one warning for the excess blank lines at EOF
283            warnings.push(LintWarning {
284                rule_name: Some(self.name().to_string()),
285                severity: Severity::Warning,
286                message: format!("Multiple consecutive blank lines {location}"),
287                line: report_line,
288                column: 1,
289                end_line: report_line,
290                end_column: 1,
291                fix: Some(Fix {
292                    range: {
293                        // Remove excess trailing newlines
294                        let keep_chars = content.len() - excess_newlines;
295                        log::debug!(
296                            "MD012 EOF: consecutive_newlines_at_end={}, blank_lines_at_eof={}, target_newlines={}, excess_newlines={}, content_len={}, keep_chars={}, range={}..{}",
297                            consecutive_newlines_at_end,
298                            blank_lines_at_eof,
299                            target_newlines,
300                            excess_newlines,
301                            content.len(),
302                            keep_chars,
303                            keep_chars,
304                            content.len()
305                        );
306                        keep_chars..content.len()
307                    },
308                    replacement: String::new(),
309                }),
310            });
311        }
312
313        Ok(warnings)
314    }
315
316    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
317        let content = ctx.content;
318
319        let mut result = Vec::new();
320        let mut blank_count = 0;
321
322        let mut in_code_block = false;
323        let mut code_block_blanks = Vec::new();
324        let mut in_front_matter = false;
325
326        // Process ALL lines (don't skip front-matter in fix mode)
327        for filtered_line in ctx.filtered_lines() {
328            let line = filtered_line.content;
329
330            // Pass through front-matter lines unchanged
331            if filtered_line.line_info.in_front_matter {
332                if !in_front_matter {
333                    // Entering front-matter: flush any accumulated blanks
334                    let allowed_blanks = blank_count.min(self.config.maximum.get());
335                    if allowed_blanks > 0 {
336                        result.extend(vec![""; allowed_blanks]);
337                    }
338                    blank_count = 0;
339                    in_front_matter = true;
340                }
341                result.push(line);
342                continue;
343            } else if in_front_matter {
344                // Exiting front-matter
345                in_front_matter = false;
346            }
347
348            // Track code blocks
349            if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
350                // Handle accumulated blank lines before code block
351                if !in_code_block {
352                    let allowed_blanks = blank_count.min(self.config.maximum.get());
353                    if allowed_blanks > 0 {
354                        result.extend(vec![""; allowed_blanks]);
355                    }
356                    blank_count = 0;
357                } else {
358                    // Add accumulated blank lines inside code block
359                    result.append(&mut code_block_blanks);
360                }
361                in_code_block = !in_code_block;
362                result.push(line);
363                continue;
364            }
365
366            if in_code_block {
367                if line.trim().is_empty() {
368                    code_block_blanks.push(line);
369                } else {
370                    result.append(&mut code_block_blanks);
371                    result.push(line);
372                }
373            } else if line.trim().is_empty() {
374                blank_count += 1;
375            } else {
376                // Add allowed blank lines before content
377                let allowed_blanks = blank_count.min(self.config.maximum.get());
378                if allowed_blanks > 0 {
379                    result.extend(vec![""; allowed_blanks]);
380                }
381                blank_count = 0;
382                result.push(line);
383            }
384        }
385
386        // Handle trailing blank lines
387        // After the loop, blank_count contains the number of trailing blank lines
388        // Add up to maximum allowed trailing blank lines
389        let allowed_trailing_blanks = blank_count.min(self.config.maximum.get());
390        if allowed_trailing_blanks > 0 {
391            result.extend(vec![""; allowed_trailing_blanks]);
392        }
393
394        // Join lines and handle final newline
395        let mut output = result.join("\n");
396        if content.ends_with('\n') {
397            output.push('\n');
398        }
399
400        Ok(output)
401    }
402
403    fn as_any(&self) -> &dyn std::any::Any {
404        self
405    }
406
407    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
408        // Skip if content is empty or doesn't have newlines (single line can't have multiple blanks)
409        ctx.content.is_empty() || !ctx.has_char('\n')
410    }
411
412    fn default_config_section(&self) -> Option<(String, toml::Value)> {
413        let default_config = MD012Config::default();
414        let json_value = serde_json::to_value(&default_config).ok()?;
415        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
416
417        if let toml::Value::Table(table) = toml_value {
418            if !table.is_empty() {
419                Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
420            } else {
421                None
422            }
423        } else {
424            None
425        }
426    }
427
428    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
429    where
430        Self: Sized,
431    {
432        let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
433        Box::new(Self::from_config_struct(rule_config))
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::lint_context::LintContext;
441
442    #[test]
443    fn test_single_blank_line_allowed() {
444        let rule = MD012NoMultipleBlanks::default();
445        let content = "Line 1\n\nLine 2\n\nLine 3";
446        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
447        let result = rule.check(&ctx).unwrap();
448        assert!(result.is_empty());
449    }
450
451    #[test]
452    fn test_multiple_blank_lines_flagged() {
453        let rule = MD012NoMultipleBlanks::default();
454        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
455        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
456        let result = rule.check(&ctx).unwrap();
457        assert_eq!(result.len(), 3); // 1 extra in first gap, 2 extra in second gap
458        assert_eq!(result[0].line, 3);
459        assert_eq!(result[1].line, 6);
460        assert_eq!(result[2].line, 7);
461    }
462
463    #[test]
464    fn test_custom_maximum() {
465        let rule = MD012NoMultipleBlanks::new(2);
466        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
467        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
468        let result = rule.check(&ctx).unwrap();
469        assert_eq!(result.len(), 1); // Only the fourth blank line is excessive
470        assert_eq!(result[0].line, 7);
471    }
472
473    #[test]
474    fn test_fix_multiple_blank_lines() {
475        let rule = MD012NoMultipleBlanks::default();
476        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
477        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
478        let fixed = rule.fix(&ctx).unwrap();
479        assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
480    }
481
482    #[test]
483    fn test_blank_lines_in_code_block() {
484        let rule = MD012NoMultipleBlanks::default();
485        let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
486        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
487        let result = rule.check(&ctx).unwrap();
488        assert!(result.is_empty()); // Blank lines inside code blocks are ignored
489    }
490
491    #[test]
492    fn test_fix_preserves_code_block_blanks() {
493        let rule = MD012NoMultipleBlanks::default();
494        let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
495        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
496        let fixed = rule.fix(&ctx).unwrap();
497        assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
498    }
499
500    #[test]
501    fn test_blank_lines_in_front_matter() {
502        let rule = MD012NoMultipleBlanks::default();
503        let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
504        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
505        let result = rule.check(&ctx).unwrap();
506        assert!(result.is_empty()); // Blank lines in front matter are ignored
507    }
508
509    #[test]
510    fn test_blank_lines_at_start() {
511        let rule = MD012NoMultipleBlanks::default();
512        let content = "\n\n\nContent";
513        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
514        let result = rule.check(&ctx).unwrap();
515        assert_eq!(result.len(), 2);
516        assert!(result[0].message.contains("at start of file"));
517    }
518
519    #[test]
520    fn test_blank_lines_at_end() {
521        let rule = MD012NoMultipleBlanks::default();
522        let content = "Content\n\n\n";
523        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
524        let result = rule.check(&ctx).unwrap();
525        assert_eq!(result.len(), 1);
526        assert!(result[0].message.contains("at end of file"));
527    }
528
529    #[test]
530    fn test_single_blank_at_eof_flagged() {
531        // Markdownlint behavior: ANY blank lines at EOF are flagged
532        let rule = MD012NoMultipleBlanks::default();
533        let content = "Content\n\n";
534        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
535        let result = rule.check(&ctx).unwrap();
536        assert_eq!(result.len(), 1);
537        assert!(result[0].message.contains("at end of file"));
538    }
539
540    #[test]
541    fn test_whitespace_only_lines() {
542        let rule = MD012NoMultipleBlanks::default();
543        let content = "Line 1\n  \n\t\nLine 2";
544        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
545        let result = rule.check(&ctx).unwrap();
546        assert_eq!(result.len(), 1); // Whitespace-only lines count as blank
547    }
548
549    #[test]
550    fn test_indented_code_blocks() {
551        let rule = MD012NoMultipleBlanks::default();
552        let content = "Text\n\n    code\n    \n    \n    more code\n\nText";
553        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
554        let result = rule.check(&ctx).unwrap();
555        // The recent changes to MD012 now detect blank lines even in indented code blocks
556        // This is actually correct behavior - we should flag the extra blank line
557        assert_eq!(result.len(), 1);
558        assert_eq!(result[0].line, 5); // Line 5 is the second blank line in the code block
559    }
560
561    #[test]
562    fn test_fix_with_final_newline() {
563        let rule = MD012NoMultipleBlanks::default();
564        let content = "Line 1\n\n\nLine 2\n";
565        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
566        let fixed = rule.fix(&ctx).unwrap();
567        assert_eq!(fixed, "Line 1\n\nLine 2\n");
568        assert!(fixed.ends_with('\n'));
569    }
570
571    #[test]
572    fn test_empty_content() {
573        let rule = MD012NoMultipleBlanks::default();
574        let content = "";
575        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
576        let result = rule.check(&ctx).unwrap();
577        assert!(result.is_empty());
578    }
579
580    #[test]
581    fn test_nested_code_blocks() {
582        let rule = MD012NoMultipleBlanks::default();
583        let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
584        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
585        let result = rule.check(&ctx).unwrap();
586        assert!(result.is_empty());
587    }
588
589    #[test]
590    fn test_unclosed_code_block() {
591        let rule = MD012NoMultipleBlanks::default();
592        let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
593        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
594        let result = rule.check(&ctx).unwrap();
595        assert!(result.is_empty()); // Unclosed code blocks still preserve blank lines
596    }
597
598    #[test]
599    fn test_mixed_fence_styles() {
600        let rule = MD012NoMultipleBlanks::default();
601        let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
602        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
603        let result = rule.check(&ctx).unwrap();
604        assert!(result.is_empty()); // Mixed fence styles should work
605    }
606
607    #[test]
608    fn test_config_from_toml() {
609        let mut config = crate::config::Config::default();
610        let mut rule_config = crate::config::RuleConfig::default();
611        rule_config
612            .values
613            .insert("maximum".to_string(), toml::Value::Integer(3));
614        config.rules.insert("MD012".to_string(), rule_config);
615
616        let rule = MD012NoMultipleBlanks::from_config(&config);
617        let content = "Line 1\n\n\n\nLine 2"; // 3 blank lines
618        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
619        let result = rule.check(&ctx).unwrap();
620        assert!(result.is_empty()); // 3 blank lines allowed with maximum=3
621    }
622
623    #[test]
624    fn test_blank_lines_between_sections() {
625        let rule = MD012NoMultipleBlanks::default();
626        let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
627        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
628        let result = rule.check(&ctx).unwrap();
629        assert_eq!(result.len(), 1);
630        assert_eq!(result[0].line, 5);
631    }
632
633    #[test]
634    fn test_fix_preserves_indented_code() {
635        let rule = MD012NoMultipleBlanks::default();
636        let content = "Text\n\n\n    code\n    \n    more code\n\n\nText";
637        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
638        let fixed = rule.fix(&ctx).unwrap();
639        // The fix removes the extra blank line, but this is expected behavior
640        assert_eq!(fixed, "Text\n\n    code\n\n    more code\n\nText");
641    }
642
643    #[test]
644    fn test_edge_case_only_blanks() {
645        let rule = MD012NoMultipleBlanks::default();
646        let content = "\n\n\n";
647        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
648        let result = rule.check(&ctx).unwrap();
649        // With the new EOF handling, we report once at EOF
650        assert_eq!(result.len(), 1);
651        assert!(result[0].message.contains("at end of file"));
652    }
653}