rumdl_lib/rules/
md012_no_multiple_blanks.rs

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