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                // Reset blank count when entering/exiting front matter
89                blank_count = 0;
90                continue;
91            }
92
93            // Check for code block boundaries
94            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
95                if !in_code_block {
96                    // Entering code block
97                    in_code_block = true;
98                    code_fence_marker = if trimmed.starts_with("```") { "```" } else { "~~~" };
99                } else if trimmed.starts_with(code_fence_marker) {
100                    // Exiting code block
101                    in_code_block = false;
102                    code_fence_marker = "";
103                }
104                // Reset blank count immediately when entering code block
105                blank_count = 0;
106                continue;
107            }
108
109            // Skip lines in code blocks or front matter
110            if in_code_block || in_front_matter {
111                // Reset counter to prevent counting across boundaries
112                blank_count = 0;
113                continue;
114            }
115
116            // Check for indented code blocks (4+ spaces)
117            let is_indented_code = line.len() >= 4 && line.starts_with("    ") && !line.trim().is_empty();
118            if is_indented_code {
119                blank_count = 0;
120                continue;
121            }
122
123            if line.trim().is_empty() {
124                if blank_count == 0 {
125                    blank_start = line_num;
126                }
127                blank_count += 1;
128                // Store line numbers that exceed the limit
129                if blank_count > self.config.maximum {
130                    lines_to_check.insert(line_num);
131                }
132            } else {
133                if blank_count > self.config.maximum {
134                    // Generate warnings for each excess blank line
135                    let location = if blank_start == 0 {
136                        "at start of file"
137                    } else {
138                        "between content"
139                    };
140
141                    // Report warnings starting from the (maximum+1)th blank line
142                    for i in self.config.maximum..blank_count {
143                        let excess_line_num = blank_start + i;
144                        if lines_to_check.contains(&excess_line_num) {
145                            let excess_line = excess_line_num + 1; // +1 for 1-indexed lines
146                            let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
147
148                            // Calculate precise character range for the entire blank line
149                            let (start_line, start_col, end_line, end_col) =
150                                calculate_line_range(excess_line, excess_line_content);
151
152                            warnings.push(LintWarning {
153                                rule_name: Some(self.name()),
154                                severity: Severity::Warning,
155                                message: format!(
156                                    "Multiple consecutive blank lines {} (Expected: {}; Actual: {})",
157                                    location, self.config.maximum, blank_count
158                                ),
159                                line: start_line,
160                                column: start_col,
161                                end_line,
162                                end_column: end_col,
163                                fix: Some(Fix {
164                                    range: {
165                                        // Remove entire line including newline
166                                        let line_start = _line_index.get_line_start_byte(excess_line).unwrap_or(0);
167                                        let line_end = _line_index
168                                            .get_line_start_byte(excess_line + 1)
169                                            .unwrap_or(line_start + 1);
170                                        line_start..line_end
171                                    },
172                                    replacement: String::new(), // Remove the excess line
173                                }),
174                            });
175                        }
176                    }
177                }
178                blank_count = 0;
179                lines_to_check.clear();
180            }
181        }
182
183        // Check for trailing blank lines
184        if blank_count > self.config.maximum {
185            let location = "at end of file";
186            for i in self.config.maximum..blank_count {
187                let excess_line_num = blank_start + i;
188                if lines_to_check.contains(&excess_line_num) {
189                    let excess_line = excess_line_num + 1;
190                    let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
191
192                    // Calculate precise character range for the entire blank line
193                    let (start_line, start_col, end_line, end_col) =
194                        calculate_line_range(excess_line, excess_line_content);
195
196                    warnings.push(LintWarning {
197                        rule_name: Some(self.name()),
198                        severity: Severity::Warning,
199                        message: format!(
200                            "Multiple consecutive blank lines {} (Expected: {}; Actual: {})",
201                            location, self.config.maximum, blank_count
202                        ),
203                        line: start_line,
204                        column: start_col,
205                        end_line,
206                        end_column: end_col,
207                        fix: Some(Fix {
208                            range: {
209                                // Remove entire line including newline
210                                let line_start = _line_index.get_line_start_byte(excess_line).unwrap_or(0);
211                                let line_end = _line_index
212                                    .get_line_start_byte(excess_line + 1)
213                                    .unwrap_or(line_start + 1);
214                                line_start..line_end
215                            },
216                            replacement: String::new(),
217                        }),
218                    });
219                }
220            }
221        }
222
223        Ok(warnings)
224    }
225
226    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
227        let content = ctx.content;
228        let _line_index = LineIndex::new(content.to_string());
229
230        let mut result = Vec::new();
231
232        let mut blank_count = 0;
233
234        let lines: Vec<&str> = content.lines().collect();
235
236        let mut in_code_block = false;
237
238        let mut in_front_matter = false;
239
240        let mut code_block_blanks = Vec::new();
241
242        for &line in lines.iter() {
243            // Track code blocks and front matter
244            if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
245                // Handle accumulated blank lines before code block
246                if !in_code_block {
247                    let allowed_blanks = blank_count.min(self.config.maximum);
248                    if allowed_blanks > 0 {
249                        result.extend(vec![""; allowed_blanks]);
250                    }
251                    blank_count = 0;
252                } else {
253                    // Add accumulated blank lines inside code block
254                    result.append(&mut code_block_blanks);
255                }
256                in_code_block = !in_code_block;
257                result.push(line);
258                continue;
259            }
260
261            if line.trim() == "---" {
262                in_front_matter = !in_front_matter;
263                if blank_count > 0 {
264                    result.extend(vec![""; blank_count]);
265                    blank_count = 0;
266                }
267                result.push(line);
268                continue;
269            }
270
271            if in_code_block {
272                if line.trim().is_empty() {
273                    code_block_blanks.push(line);
274                } else {
275                    result.append(&mut code_block_blanks);
276                    result.push(line);
277                }
278            } else if in_front_matter {
279                if blank_count > 0 {
280                    result.extend(vec![""; blank_count]);
281                    blank_count = 0;
282                }
283                result.push(line);
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);
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        if !in_code_block {
299            let allowed_blanks = blank_count.min(self.config.maximum);
300            if allowed_blanks > 0 {
301                result.extend(vec![""; allowed_blanks]);
302            }
303        }
304
305        // Join lines and handle final newline
306
307        let mut output = result.join("\n");
308        if content.ends_with('\n') {
309            output.push('\n');
310        }
311
312        Ok(output)
313    }
314
315    fn as_any(&self) -> &dyn std::any::Any {
316        self
317    }
318
319    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
320        // Skip if content is empty or doesn't have consecutive newlines
321        ctx.content.is_empty() || !ctx.content.contains("\n\n")
322    }
323
324    fn default_config_section(&self) -> Option<(String, toml::Value)> {
325        let default_config = MD012Config::default();
326        let json_value = serde_json::to_value(&default_config).ok()?;
327        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
328
329        if let toml::Value::Table(table) = toml_value {
330            if !table.is_empty() {
331                Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
332            } else {
333                None
334            }
335        } else {
336            None
337        }
338    }
339
340    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
341    where
342        Self: Sized,
343    {
344        let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
345        Box::new(Self::from_config_struct(rule_config))
346    }
347}
348
349impl crate::utils::document_structure::DocumentStructureExtensions for MD012NoMultipleBlanks {
350    fn has_relevant_elements(
351        &self,
352        ctx: &crate::lint_context::LintContext,
353        _structure: &crate::utils::document_structure::DocumentStructure,
354    ) -> bool {
355        // MD012 checks for consecutive blank lines, so it's relevant for any non-empty content
356        !ctx.content.is_empty()
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::lint_context::LintContext;
364
365    #[test]
366    fn test_single_blank_line_allowed() {
367        let rule = MD012NoMultipleBlanks::default();
368        let content = "Line 1\n\nLine 2\n\nLine 3";
369        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
370        let result = rule.check(&ctx).unwrap();
371        assert!(result.is_empty());
372    }
373
374    #[test]
375    fn test_multiple_blank_lines_flagged() {
376        let rule = MD012NoMultipleBlanks::default();
377        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
378        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
379        let result = rule.check(&ctx).unwrap();
380        assert_eq!(result.len(), 3); // 1 extra in first gap, 2 extra in second gap
381        assert_eq!(result[0].line, 3);
382        assert_eq!(result[1].line, 6);
383        assert_eq!(result[2].line, 7);
384    }
385
386    #[test]
387    fn test_custom_maximum() {
388        let rule = MD012NoMultipleBlanks::new(2);
389        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
390        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
391        let result = rule.check(&ctx).unwrap();
392        assert_eq!(result.len(), 1); // Only the fourth blank line is excessive
393        assert_eq!(result[0].line, 7);
394    }
395
396    #[test]
397    fn test_fix_multiple_blank_lines() {
398        let rule = MD012NoMultipleBlanks::default();
399        let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
400        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
401        let fixed = rule.fix(&ctx).unwrap();
402        assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
403    }
404
405    #[test]
406    fn test_blank_lines_in_code_block() {
407        let rule = MD012NoMultipleBlanks::default();
408        let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
409        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
410        let result = rule.check(&ctx).unwrap();
411        assert!(result.is_empty()); // Blank lines inside code blocks are ignored
412    }
413
414    #[test]
415    fn test_fix_preserves_code_block_blanks() {
416        let rule = MD012NoMultipleBlanks::default();
417        let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
418        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
419        let fixed = rule.fix(&ctx).unwrap();
420        assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
421    }
422
423    #[test]
424    fn test_blank_lines_in_front_matter() {
425        let rule = MD012NoMultipleBlanks::default();
426        let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
427        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
428        let result = rule.check(&ctx).unwrap();
429        assert!(result.is_empty()); // Blank lines in front matter are ignored
430    }
431
432    #[test]
433    fn test_blank_lines_at_start() {
434        let rule = MD012NoMultipleBlanks::default();
435        let content = "\n\n\nContent";
436        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
437        let result = rule.check(&ctx).unwrap();
438        assert_eq!(result.len(), 2);
439        assert!(result[0].message.contains("at start of file"));
440    }
441
442    #[test]
443    fn test_blank_lines_at_end() {
444        let rule = MD012NoMultipleBlanks::default();
445        let content = "Content\n\n\n";
446        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
447        let result = rule.check(&ctx).unwrap();
448        assert_eq!(result.len(), 1);
449        assert!(result[0].message.contains("at end of file"));
450    }
451
452    #[test]
453    fn test_whitespace_only_lines() {
454        let rule = MD012NoMultipleBlanks::default();
455        let content = "Line 1\n  \n\t\nLine 2";
456        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
457        let result = rule.check(&ctx).unwrap();
458        assert_eq!(result.len(), 1); // Whitespace-only lines count as blank
459    }
460
461    #[test]
462    fn test_indented_code_blocks() {
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);
466        let result = rule.check(&ctx).unwrap();
467        assert!(result.is_empty()); // Blank lines in indented code blocks are preserved
468    }
469
470    #[test]
471    fn test_fix_with_final_newline() {
472        let rule = MD012NoMultipleBlanks::default();
473        let content = "Line 1\n\n\nLine 2\n";
474        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
475        let fixed = rule.fix(&ctx).unwrap();
476        assert_eq!(fixed, "Line 1\n\nLine 2\n");
477        assert!(fixed.ends_with('\n'));
478    }
479
480    #[test]
481    fn test_empty_content() {
482        let rule = MD012NoMultipleBlanks::default();
483        let content = "";
484        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
485        let result = rule.check(&ctx).unwrap();
486        assert!(result.is_empty());
487    }
488
489    #[test]
490    fn test_nested_code_blocks() {
491        let rule = MD012NoMultipleBlanks::default();
492        let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
493        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
494        let result = rule.check(&ctx).unwrap();
495        assert!(result.is_empty());
496    }
497
498    #[test]
499    fn test_unclosed_code_block() {
500        let rule = MD012NoMultipleBlanks::default();
501        let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
502        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
503        let result = rule.check(&ctx).unwrap();
504        assert!(result.is_empty()); // Unclosed code blocks still preserve blank lines
505    }
506
507    #[test]
508    fn test_mixed_fence_styles() {
509        let rule = MD012NoMultipleBlanks::default();
510        let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
511        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
512        let result = rule.check(&ctx).unwrap();
513        assert!(result.is_empty()); // Mixed fence styles should work
514    }
515
516    #[test]
517    fn test_config_from_toml() {
518        let mut config = crate::config::Config::default();
519        let mut rule_config = crate::config::RuleConfig::default();
520        rule_config
521            .values
522            .insert("maximum".to_string(), toml::Value::Integer(3));
523        config.rules.insert("MD012".to_string(), rule_config);
524
525        let rule = MD012NoMultipleBlanks::from_config(&config);
526        let content = "Line 1\n\n\n\nLine 2"; // 3 blank lines
527        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
528        let result = rule.check(&ctx).unwrap();
529        assert!(result.is_empty()); // 3 blank lines allowed with maximum=3
530    }
531
532    #[test]
533    fn test_blank_lines_between_sections() {
534        let rule = MD012NoMultipleBlanks::default();
535        let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
536        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
537        let result = rule.check(&ctx).unwrap();
538        assert_eq!(result.len(), 1);
539        assert_eq!(result[0].line, 5);
540    }
541
542    #[test]
543    fn test_fix_preserves_indented_code() {
544        let rule = MD012NoMultipleBlanks::default();
545        let content = "Text\n\n\n    code\n    \n    more code\n\n\nText";
546        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
547        let fixed = rule.fix(&ctx).unwrap();
548        // The fix removes the extra blank line, but this is expected behavior
549        assert_eq!(fixed, "Text\n\n    code\n\n    more code\n\nText");
550    }
551
552    #[test]
553    fn test_edge_case_only_blanks() {
554        let rule = MD012NoMultipleBlanks::default();
555        let content = "\n\n\n";
556        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
557        let result = rule.check(&ctx).unwrap();
558        assert_eq!(result.len(), 2); // Two excessive blank lines
559    }
560}