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