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