rumdl_lib/rules/
md022_blanks_around_headings.rs

1/// Rule MD022: Headings should be surrounded by blank lines
2///
3/// See [docs/md022.md](../../docs/md022.md) for full documentation, configuration, and examples.
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::range_utils::calculate_heading_range;
7use toml;
8
9mod md022_config;
10use md022_config::MD022Config;
11
12///
13/// This rule enforces consistent spacing around headings to improve document readability
14/// and visual structure.
15///
16/// ## Purpose
17///
18/// - **Readability**: Blank lines create visual separation, making headings stand out
19/// - **Parsing**: Many Markdown parsers require blank lines around headings for proper rendering
20/// - **Consistency**: Creates a uniform document style throughout
21/// - **Focus**: Helps readers identify and focus on section transitions
22///
23/// ## Configuration Options
24///
25/// The rule supports customizing the number of blank lines required:
26///
27/// ```yaml
28/// MD022:
29///   lines_above: 1  # Number of blank lines required above headings (default: 1)
30///   lines_below: 1  # Number of blank lines required below headings (default: 1)
31/// ```
32///
33/// ## Examples
34///
35/// ### Correct (with default configuration)
36///
37/// ```markdown
38/// Regular paragraph text.
39///
40/// # Heading 1
41///
42/// Content under heading 1.
43///
44/// ## Heading 2
45///
46/// More content here.
47/// ```
48///
49/// ### Incorrect (with default configuration)
50///
51/// ```markdown
52/// Regular paragraph text.
53/// # Heading 1
54/// Content under heading 1.
55/// ## Heading 2
56/// More content here.
57/// ```
58///
59/// ## Special Cases
60///
61/// This rule handles several special cases:
62///
63/// - **First Heading**: The first heading in a document doesn't require blank lines above
64///   if it appears at the very start of the document
65/// - **Front Matter**: YAML front matter is detected and skipped
66/// - **Code Blocks**: Headings inside code blocks are ignored
67/// - **Document Start/End**: Adjusts requirements for headings at the beginning or end of a document
68///
69/// ## Fix Behavior
70///
71/// When applying automatic fixes, this rule:
72/// - Adds the required number of blank lines above headings
73/// - Adds the required number of blank lines below headings
74/// - Preserves document structure and existing content
75///
76/// ## Performance Considerations
77///
78/// The rule is optimized for performance with:
79/// - Efficient line counting algorithms
80/// - Proper handling of front matter
81/// - Smart code block detection
82///
83#[derive(Clone, Default)]
84pub struct MD022BlanksAroundHeadings {
85    config: MD022Config,
86}
87
88impl MD022BlanksAroundHeadings {
89    /// Create a new instance of the rule with default values:
90    /// lines_above = 1, lines_below = 1
91    pub fn new() -> Self {
92        Self {
93            config: MD022Config::default(),
94        }
95    }
96
97    /// Create with custom numbers of blank lines (applies to all heading levels)
98    pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
99        use md022_config::HeadingLevelConfig;
100        Self {
101            config: MD022Config {
102                lines_above: HeadingLevelConfig::scalar(lines_above),
103                lines_below: HeadingLevelConfig::scalar(lines_below),
104                allowed_at_start: true,
105            },
106        }
107    }
108
109    pub fn from_config_struct(config: MD022Config) -> Self {
110        Self { config }
111    }
112
113    /// Fix a document by adding appropriate blank lines around headings
114    fn _fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
115        // Content is normalized to LF at I/O boundary
116        let line_ending = "\n";
117        let had_trailing_newline = ctx.content.ends_with('\n');
118        let mut result = Vec::new();
119        let mut skip_next = false;
120
121        let heading_at_start_idx = {
122            let mut found_non_transparent = false;
123            ctx.lines.iter().enumerate().find_map(|(i, line)| {
124                // Only count valid headings (skip malformed ones like `#NoSpace`)
125                if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
126                    Some(i)
127                } else {
128                    // HTML comments and blank lines are "transparent" - they don't count as content
129                    // that would prevent a heading from being "at document start"
130                    if !line.is_blank && !line.in_html_comment {
131                        let trimmed = line.content(ctx.content).trim();
132                        // Check for single-line HTML comments too
133                        if !(trimmed.starts_with("<!--") && trimmed.ends_with("-->")) {
134                            found_non_transparent = true;
135                        }
136                    }
137                    None
138                }
139            })
140        };
141
142        for (i, line_info) in ctx.lines.iter().enumerate() {
143            if skip_next {
144                skip_next = false;
145                continue;
146            }
147            let line = line_info.content(ctx.content);
148
149            if line_info.in_code_block {
150                result.push(line.to_string());
151                continue;
152            }
153
154            // Check if it's a heading
155            if let Some(heading) = &line_info.heading {
156                // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
157                if !heading.is_valid {
158                    result.push(line.to_string());
159                    continue;
160                }
161
162                // This is a heading line (ATX or Setext content)
163                let is_first_heading = Some(i) == heading_at_start_idx;
164                let heading_level = heading.level as usize;
165
166                // Count existing blank lines above in the result, skipping HTML comments
167                let mut blank_lines_above = 0;
168                let mut check_idx = result.len();
169                while check_idx > 0 {
170                    let prev_line = &result[check_idx - 1];
171                    let trimmed = prev_line.trim();
172                    if trimmed.is_empty() {
173                        blank_lines_above += 1;
174                        check_idx -= 1;
175                    } else if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
176                        // Skip HTML comments - they are transparent for blank line counting
177                        check_idx -= 1;
178                    } else {
179                        break;
180                    }
181                }
182
183                // Determine how many blank lines we need above
184                let requirement_above = self.config.lines_above.get_for_level(heading_level);
185                let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
186                    0
187                } else {
188                    requirement_above.required_count().unwrap_or(0)
189                };
190
191                // Add missing blank lines above if needed
192                while blank_lines_above < needed_blanks_above {
193                    result.push(String::new());
194                    blank_lines_above += 1;
195                }
196
197                // Add the heading line
198                result.push(line.to_string());
199
200                // For Setext headings, also add the underline immediately
201                if matches!(
202                    heading.style,
203                    crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
204                ) {
205                    // Add the underline (next line)
206                    if i + 1 < ctx.lines.len() {
207                        result.push(ctx.lines[i + 1].content(ctx.content).to_string());
208                        skip_next = true; // Skip the underline in the main loop
209                    }
210
211                    // Now check blank lines below the underline
212                    let mut blank_lines_below = 0;
213                    let mut next_content_line_idx = None;
214                    for j in (i + 2)..ctx.lines.len() {
215                        if ctx.lines[j].is_blank {
216                            blank_lines_below += 1;
217                        } else {
218                            next_content_line_idx = Some(j);
219                            break;
220                        }
221                    }
222
223                    // Check if the next non-blank line is special
224                    let next_is_special = if let Some(idx) = next_content_line_idx {
225                        let next_line = &ctx.lines[idx];
226                        next_line.list_item.is_some() || {
227                            let trimmed = next_line.content(ctx.content).trim();
228                            (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
229                                && (trimmed.len() == 3
230                                    || (trimmed.len() > 3
231                                        && trimmed
232                                            .chars()
233                                            .nth(3)
234                                            .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
235                        }
236                    } else {
237                        false
238                    };
239
240                    // Add missing blank lines below if needed
241                    let requirement_below = self.config.lines_below.get_for_level(heading_level);
242                    let needed_blanks_below = if next_is_special {
243                        0
244                    } else {
245                        requirement_below.required_count().unwrap_or(0)
246                    };
247                    if blank_lines_below < needed_blanks_below {
248                        for _ in 0..(needed_blanks_below - blank_lines_below) {
249                            result.push(String::new());
250                        }
251                    }
252                } else {
253                    // For ATX headings, check blank lines below
254                    let mut blank_lines_below = 0;
255                    let mut next_content_line_idx = None;
256                    for j in (i + 1)..ctx.lines.len() {
257                        if ctx.lines[j].is_blank {
258                            blank_lines_below += 1;
259                        } else {
260                            next_content_line_idx = Some(j);
261                            break;
262                        }
263                    }
264
265                    // Check if the next non-blank line is special
266                    let next_is_special = if let Some(idx) = next_content_line_idx {
267                        let next_line = &ctx.lines[idx];
268                        next_line.list_item.is_some() || {
269                            let trimmed = next_line.content(ctx.content).trim();
270                            (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
271                                && (trimmed.len() == 3
272                                    || (trimmed.len() > 3
273                                        && trimmed
274                                            .chars()
275                                            .nth(3)
276                                            .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
277                        }
278                    } else {
279                        false
280                    };
281
282                    // Add missing blank lines below if needed
283                    let requirement_below = self.config.lines_below.get_for_level(heading_level);
284                    let needed_blanks_below = if next_is_special {
285                        0
286                    } else {
287                        requirement_below.required_count().unwrap_or(0)
288                    };
289                    if blank_lines_below < needed_blanks_below {
290                        for _ in 0..(needed_blanks_below - blank_lines_below) {
291                            result.push(String::new());
292                        }
293                    }
294                }
295            } else {
296                // Regular line - just add it
297                result.push(line.to_string());
298            }
299        }
300
301        let joined = result.join(line_ending);
302
303        // Preserve original trailing newline behavior
304        // Content is normalized to LF at I/O boundary
305        if had_trailing_newline && !joined.ends_with('\n') {
306            format!("{joined}{line_ending}")
307        } else if !had_trailing_newline && joined.ends_with('\n') {
308            // Remove trailing newline if original didn't have one
309            joined[..joined.len() - 1].to_string()
310        } else {
311            joined
312        }
313    }
314}
315
316impl Rule for MD022BlanksAroundHeadings {
317    fn name(&self) -> &'static str {
318        "MD022"
319    }
320
321    fn description(&self) -> &'static str {
322        "Headings should be surrounded by blank lines"
323    }
324
325    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
326        let mut result = Vec::new();
327
328        // Skip if empty document
329        if ctx.lines.is_empty() {
330            return Ok(result);
331        }
332
333        // Content is normalized to LF at I/O boundary
334        let line_ending = "\n";
335
336        let heading_at_start_idx = {
337            let mut found_non_transparent = false;
338            ctx.lines.iter().enumerate().find_map(|(i, line)| {
339                // Only count valid headings (skip malformed ones like `#NoSpace`)
340                if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
341                    Some(i)
342                } else {
343                    // HTML comments and blank lines are "transparent" - they don't count as content
344                    // that would prevent a heading from being "at document start"
345                    if !line.is_blank && !line.in_html_comment {
346                        let trimmed = line.content(ctx.content).trim();
347                        // Check for single-line HTML comments too
348                        if !(trimmed.starts_with("<!--") && trimmed.ends_with("-->")) {
349                            found_non_transparent = true;
350                        }
351                    }
352                    None
353                }
354            })
355        };
356
357        // Collect all headings first to batch process
358        let mut heading_violations = Vec::new();
359        let mut processed_headings = std::collections::HashSet::new();
360
361        for (line_num, line_info) in ctx.lines.iter().enumerate() {
362            // Skip if already processed or not a heading
363            if processed_headings.contains(&line_num) || line_info.heading.is_none() {
364                continue;
365            }
366
367            let heading = line_info.heading.as_ref().unwrap();
368
369            // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
370            if !heading.is_valid {
371                continue;
372            }
373
374            let heading_level = heading.level as usize;
375
376            // Note: Setext underline lines have heading=None, so they're already
377            // skipped by the check at line 351. No additional check needed here.
378
379            processed_headings.insert(line_num);
380
381            // Check if this heading is at document start
382            let is_first_heading = Some(line_num) == heading_at_start_idx;
383
384            // Get configured blank line requirements for this heading level
385            let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
386            let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
387
388            // Count blank lines above if needed
389            let should_check_above =
390                required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
391            if should_check_above {
392                let mut blank_lines_above = 0;
393                let mut hit_frontmatter_end = false;
394                for j in (0..line_num).rev() {
395                    let line_content = ctx.lines[j].content(ctx.content);
396                    let trimmed = line_content.trim();
397                    if ctx.lines[j].is_blank {
398                        blank_lines_above += 1;
399                    } else if ctx.lines[j].in_html_comment || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
400                    {
401                        // Skip HTML comments - they are transparent for blank line counting
402                        continue;
403                    } else if ctx.lines[j].in_front_matter {
404                        // Skip frontmatter - first heading after frontmatter doesn't need blank line above
405                        // Note: We only check in_front_matter flag, NOT the string "---", because
406                        // a standalone "---" is a horizontal rule and should NOT exempt headings
407                        // from requiring blank lines above
408                        hit_frontmatter_end = true;
409                        break;
410                    } else {
411                        break;
412                    }
413                }
414                let required = required_above_count.unwrap();
415                if !hit_frontmatter_end && blank_lines_above < required {
416                    let needed_blanks = required - blank_lines_above;
417                    heading_violations.push((line_num, "above", needed_blanks, heading_level));
418                }
419            }
420
421            // Determine the effective last line of the heading
422            let effective_last_line = if matches!(
423                heading.style,
424                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
425            ) {
426                line_num + 1 // For Setext, include the underline
427            } else {
428                line_num
429            };
430
431            // Check blank lines below
432            if effective_last_line < ctx.lines.len() - 1 {
433                // Find next non-blank line
434                let mut next_non_blank_idx = effective_last_line + 1;
435                while next_non_blank_idx < ctx.lines.len() && ctx.lines[next_non_blank_idx].is_blank {
436                    next_non_blank_idx += 1;
437                }
438
439                // Check if next line is a code fence or list item
440                let next_line_is_special = next_non_blank_idx < ctx.lines.len() && {
441                    let next_line = &ctx.lines[next_non_blank_idx];
442                    let next_trimmed = next_line.content(ctx.content).trim();
443
444                    // Check for code fence
445                    let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
446                        && (next_trimmed.len() == 3
447                            || (next_trimmed.len() > 3
448                                && next_trimmed
449                                    .chars()
450                                    .nth(3)
451                                    .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
452
453                    // Check for list item
454                    let is_list_item = next_line.list_item.is_some();
455
456                    is_code_fence || is_list_item
457                };
458
459                // Only generate warning if next line is NOT a code fence or list item
460                if !next_line_is_special && let Some(required) = required_below_count {
461                    // Count blank lines below
462                    let blank_lines_below = next_non_blank_idx - effective_last_line - 1;
463
464                    if blank_lines_below < required {
465                        let needed_blanks = required - blank_lines_below;
466                        heading_violations.push((line_num, "below", needed_blanks, heading_level));
467                    }
468                }
469            }
470        }
471
472        // Generate warnings for all violations
473        for (heading_line, position, needed_blanks, heading_level) in heading_violations {
474            let heading_display_line = heading_line + 1; // 1-indexed for display
475            let line_info = &ctx.lines[heading_line];
476
477            // Calculate precise character range for the heading
478            let (start_line, start_col, end_line, end_col) =
479                calculate_heading_range(heading_display_line, line_info.content(ctx.content));
480
481            let required_above_count = self
482                .config
483                .lines_above
484                .get_for_level(heading_level)
485                .required_count()
486                .expect("Violations only generated for limited 'above' requirements");
487            let required_below_count = self
488                .config
489                .lines_below
490                .get_for_level(heading_level)
491                .required_count()
492                .expect("Violations only generated for limited 'below' requirements");
493
494            let (message, insertion_point) = match position {
495                "above" => (
496                    format!(
497                        "Expected {} blank {} above heading",
498                        required_above_count,
499                        if required_above_count == 1 { "line" } else { "lines" }
500                    ),
501                    heading_line, // Insert before the heading line
502                ),
503                "below" => {
504                    // For Setext headings, insert after the underline
505                    let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
506                        matches!(
507                            h.style,
508                            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
509                        )
510                    }) {
511                        heading_line + 2
512                    } else {
513                        heading_line + 1
514                    };
515
516                    (
517                        format!(
518                            "Expected {} blank {} below heading",
519                            required_below_count,
520                            if required_below_count == 1 { "line" } else { "lines" }
521                        ),
522                        insert_after,
523                    )
524                }
525                _ => continue,
526            };
527
528            // Calculate byte range for insertion
529            let byte_range = if insertion_point == 0 && position == "above" {
530                // Insert at beginning of document (only for "above" case at line 0)
531                0..0
532            } else if position == "above" && insertion_point > 0 {
533                // For "above", insert at the start of the heading line
534                ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
535            } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
536                // For "below", insert after the line
537                let line_idx = insertion_point - 1;
538                let line_end_offset = if line_idx + 1 < ctx.lines.len() {
539                    ctx.lines[line_idx + 1].byte_offset
540                } else {
541                    ctx.content.len()
542                };
543                line_end_offset..line_end_offset
544            } else {
545                // Insert at end of file
546                let content_len = ctx.content.len();
547                content_len..content_len
548            };
549
550            result.push(LintWarning {
551                rule_name: Some(self.name().to_string()),
552                message,
553                line: start_line,
554                column: start_col,
555                end_line,
556                end_column: end_col,
557                severity: Severity::Warning,
558                fix: Some(Fix {
559                    range: byte_range,
560                    replacement: line_ending.repeat(needed_blanks),
561                }),
562            });
563        }
564
565        Ok(result)
566    }
567
568    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
569        if ctx.content.is_empty() {
570            return Ok(ctx.content.to_string());
571        }
572
573        // Use a consolidated fix that avoids adding multiple blank lines
574        let fixed = self._fix_content(ctx);
575
576        Ok(fixed)
577    }
578
579    /// Get the category of this rule for selective processing
580    fn category(&self) -> RuleCategory {
581        RuleCategory::Heading
582    }
583
584    /// Check if this rule should be skipped
585    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
586        // Fast path: check if document likely has headings
587        if ctx.content.is_empty() || !ctx.likely_has_headings() {
588            return true;
589        }
590        // Verify headings actually exist
591        ctx.lines.iter().all(|line| line.heading.is_none())
592    }
593
594    fn as_any(&self) -> &dyn std::any::Any {
595        self
596    }
597
598    fn default_config_section(&self) -> Option<(String, toml::Value)> {
599        let default_config = MD022Config::default();
600        let json_value = serde_json::to_value(&default_config).ok()?;
601        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
602
603        if let toml::Value::Table(table) = toml_value {
604            if !table.is_empty() {
605                Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
606            } else {
607                None
608            }
609        } else {
610            None
611        }
612    }
613
614    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
615    where
616        Self: Sized,
617    {
618        let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
619        Box::new(Self::from_config_struct(rule_config))
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use crate::lint_context::LintContext;
627
628    #[test]
629    fn test_valid_headings() {
630        let rule = MD022BlanksAroundHeadings::default();
631        let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
632        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633        let result = rule.check(&ctx).unwrap();
634        assert!(result.is_empty());
635    }
636
637    #[test]
638    fn test_missing_blank_above() {
639        let rule = MD022BlanksAroundHeadings::default();
640        let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
641        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642        let result = rule.check(&ctx).unwrap();
643        assert_eq!(result.len(), 0); // No warning for first heading
644
645        let fixed = rule.fix(&ctx).unwrap();
646
647        // Test for the ability to handle the content without breaking it
648        // Don't check for exact string equality which may break with implementation changes
649        assert!(fixed.contains("# Heading 1"));
650        assert!(fixed.contains("Some content."));
651        assert!(fixed.contains("## Heading 2"));
652        assert!(fixed.contains("More content."));
653    }
654
655    #[test]
656    fn test_missing_blank_below() {
657        let rule = MD022BlanksAroundHeadings::default();
658        let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
659        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
660        let result = rule.check(&ctx).unwrap();
661        assert_eq!(result.len(), 1);
662        assert_eq!(result[0].line, 2);
663
664        // Test the fix
665        let fixed = rule.fix(&ctx).unwrap();
666        assert!(fixed.contains("# Heading 1\n\nSome content"));
667    }
668
669    #[test]
670    fn test_missing_blank_above_and_below() {
671        let rule = MD022BlanksAroundHeadings::default();
672        let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
673        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
674        let result = rule.check(&ctx).unwrap();
675        assert_eq!(result.len(), 3); // Missing blanks: below first heading, above second heading, below second heading
676
677        // Test the fix
678        let fixed = rule.fix(&ctx).unwrap();
679        assert!(fixed.contains("# Heading 1\n\nSome content"));
680        assert!(fixed.contains("Some content.\n\n## Heading 2"));
681        assert!(fixed.contains("## Heading 2\n\nMore content"));
682    }
683
684    #[test]
685    fn test_fix_headings() {
686        let rule = MD022BlanksAroundHeadings::default();
687        let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
688        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689        let result = rule.fix(&ctx).unwrap();
690
691        let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
692        assert_eq!(result, expected);
693    }
694
695    #[test]
696    fn test_consecutive_headings_pattern() {
697        let rule = MD022BlanksAroundHeadings::default();
698        let content = "# Heading 1\n## Heading 2\n### Heading 3";
699        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700        let result = rule.fix(&ctx).unwrap();
701
702        // Using more specific assertions to check the structure
703        let lines: Vec<&str> = result.lines().collect();
704        assert!(!lines.is_empty());
705
706        // Find the positions of the headings
707        let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
708        let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
709        let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
710
711        // Verify blank lines between headings
712        assert!(
713            h2_pos > h1_pos + 1,
714            "Should have at least one blank line after first heading"
715        );
716        assert!(
717            h3_pos > h2_pos + 1,
718            "Should have at least one blank line after second heading"
719        );
720
721        // Verify there's a blank line between h1 and h2
722        assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
723
724        // Verify there's a blank line between h2 and h3
725        assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
726    }
727
728    #[test]
729    fn test_blanks_around_setext_headings() {
730        let rule = MD022BlanksAroundHeadings::default();
731        let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
732        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733        let result = rule.fix(&ctx).unwrap();
734
735        // Check that the fix follows requirements without being too rigid about the exact output format
736        let lines: Vec<&str> = result.lines().collect();
737
738        // Verify key elements are present
739        assert!(result.contains("Heading 1"));
740        assert!(result.contains("========="));
741        assert!(result.contains("Some content."));
742        assert!(result.contains("Heading 2"));
743        assert!(result.contains("---------"));
744        assert!(result.contains("More content."));
745
746        // Verify structure ensures blank lines are added after headings
747        let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
748        let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
749        assert!(
750            some_content_idx > heading1_marker_idx + 1,
751            "Should have a blank line after the first heading"
752        );
753
754        let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
755        let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
756        assert!(
757            more_content_idx > heading2_marker_idx + 1,
758            "Should have a blank line after the second heading"
759        );
760
761        // Verify that the fixed content has no warnings
762        let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
763        let fixed_warnings = rule.check(&fixed_ctx).unwrap();
764        assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
765    }
766
767    #[test]
768    fn test_fix_specific_blank_line_cases() {
769        let rule = MD022BlanksAroundHeadings::default();
770
771        // Case 1: Testing consecutive headings
772        let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
773        let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
774        let result1 = rule.fix(&ctx1).unwrap();
775        // Verify structure rather than exact content as the fix implementation may vary
776        assert!(result1.contains("# Heading 1"));
777        assert!(result1.contains("## Heading 2"));
778        assert!(result1.contains("### Heading 3"));
779        // Ensure each heading has a blank line after it
780        let lines: Vec<&str> = result1.lines().collect();
781        let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
782        let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
783        assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
784        assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
785
786        // Case 2: Headings with content
787        let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
788        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
789        let result2 = rule.fix(&ctx2).unwrap();
790        // Verify structure
791        assert!(result2.contains("# Heading 1"));
792        assert!(result2.contains("Content under heading 1"));
793        assert!(result2.contains("## Heading 2"));
794        // Check spacing
795        let lines2: Vec<&str> = result2.lines().collect();
796        let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
797        let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
798        assert!(
799            lines2[h1_pos2 + 1].trim().is_empty(),
800            "Should have a blank line after heading 1"
801        );
802
803        // Case 3: Multiple consecutive headings with blank lines preserved
804        let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
805        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
806        let result3 = rule.fix(&ctx3).unwrap();
807        // Just verify it doesn't crash and properly formats headings
808        assert!(result3.contains("# Heading 1"));
809        assert!(result3.contains("## Heading 2"));
810        assert!(result3.contains("### Heading 3"));
811        assert!(result3.contains("Content"));
812    }
813
814    #[test]
815    fn test_fix_preserves_existing_blank_lines() {
816        let rule = MD022BlanksAroundHeadings::new();
817        let content = "# Title
818
819## Section 1
820
821Content here.
822
823## Section 2
824
825More content.
826### Missing Blank Above
827
828Even more content.
829
830## Section 3
831
832Final content.";
833
834        let expected = "# Title
835
836## Section 1
837
838Content here.
839
840## Section 2
841
842More content.
843
844### Missing Blank Above
845
846Even more content.
847
848## Section 3
849
850Final content.";
851
852        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853        let result = rule._fix_content(&ctx);
854        assert_eq!(
855            result, expected,
856            "Fix should only add missing blank lines, never remove existing ones"
857        );
858    }
859
860    #[test]
861    fn test_fix_preserves_trailing_newline() {
862        let rule = MD022BlanksAroundHeadings::new();
863
864        // Test with trailing newline
865        let content_with_newline = "# Title\nContent here.\n";
866        let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
867        let result = rule.fix(&ctx).unwrap();
868        assert!(result.ends_with('\n'), "Should preserve trailing newline");
869
870        // Test without trailing newline
871        let content_without_newline = "# Title\nContent here.";
872        let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
873        let result = rule.fix(&ctx).unwrap();
874        assert!(
875            !result.ends_with('\n'),
876            "Should not add trailing newline if original didn't have one"
877        );
878    }
879
880    #[test]
881    fn test_fix_does_not_add_blank_lines_before_lists() {
882        let rule = MD022BlanksAroundHeadings::new();
883        let content = "## Configuration\n\nThis rule has the following configuration options:\n\n- `option1`: Description of option 1.\n- `option2`: Description of option 2.\n\n## Another Section\n\nSome content here.";
884
885        let expected = "## Configuration\n\nThis rule has the following configuration options:\n\n- `option1`: Description of option 1.\n- `option2`: Description of option 2.\n\n## Another Section\n\nSome content here.";
886
887        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888        let result = rule._fix_content(&ctx);
889        assert_eq!(result, expected, "Fix should not add blank lines before lists");
890    }
891
892    #[test]
893    fn test_per_level_configuration_no_blank_above_h1() {
894        use md022_config::HeadingLevelConfig;
895
896        // Configure: no blank above H1, 1 blank above H2-H6
897        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
898            lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
899            lines_below: HeadingLevelConfig::scalar(1),
900            allowed_at_start: false, // Disable special handling for first heading
901        });
902
903        // H1 without blank above should be OK
904        let content = "Some text\n# Heading 1\n\nMore text";
905        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
906        let warnings = rule.check(&ctx).unwrap();
907        assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
908
909        // H2 without blank above should trigger warning
910        let content = "Some text\n## Heading 2\n\nMore text";
911        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
912        let warnings = rule.check(&ctx).unwrap();
913        assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
914        assert!(warnings[0].message.contains("above"));
915    }
916
917    #[test]
918    fn test_per_level_configuration_different_requirements() {
919        use md022_config::HeadingLevelConfig;
920
921        // Configure: 0 blank above H1, 1 above H2-H3, 2 above H4-H6
922        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
923            lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
924            lines_below: HeadingLevelConfig::scalar(1),
925            allowed_at_start: false,
926        });
927
928        let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
929        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930        let warnings = rule.check(&ctx).unwrap();
931
932        // Should have no warnings - all headings satisfy their level-specific requirements
933        assert_eq!(
934            warnings.len(),
935            0,
936            "All headings should satisfy level-specific requirements"
937        );
938    }
939
940    #[test]
941    fn test_per_level_configuration_violations() {
942        use md022_config::HeadingLevelConfig;
943
944        // Configure: H4 needs 2 blanks above
945        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
946            lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
947            lines_below: HeadingLevelConfig::scalar(1),
948            allowed_at_start: false,
949        });
950
951        // H4 with only 1 blank above should trigger warning
952        let content = "Text\n\n#### Heading 4\n\nMore text";
953        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954        let warnings = rule.check(&ctx).unwrap();
955
956        assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
957        assert!(warnings[0].message.contains("2 blank lines above"));
958    }
959
960    #[test]
961    fn test_per_level_fix_different_levels() {
962        use md022_config::HeadingLevelConfig;
963
964        // Configure: 0 blank above H1, 1 above H2, 2 above H3+
965        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
966            lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
967            lines_below: HeadingLevelConfig::scalar(1),
968            allowed_at_start: false,
969        });
970
971        let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
972        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
973        let fixed = rule.fix(&ctx).unwrap();
974
975        // Verify structure: H1 gets 0 blanks above, H2 gets 1, H3 gets 2
976        assert!(fixed.contains("Text\n# H1\n\nContent"));
977        assert!(fixed.contains("Content\n\n## H2\n\nContent"));
978        assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
979    }
980
981    #[test]
982    fn test_per_level_below_configuration() {
983        use md022_config::HeadingLevelConfig;
984
985        // Configure: different blank line requirements below headings
986        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
987            lines_above: HeadingLevelConfig::scalar(1),
988            lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), // H1 needs 2 blanks below
989            allowed_at_start: true,
990        });
991
992        // H1 with only 1 blank below should trigger warning
993        let content = "# Heading 1\n\nSome text";
994        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995        let warnings = rule.check(&ctx).unwrap();
996
997        assert_eq!(
998            warnings.len(),
999            1,
1000            "H1 with insufficient blanks below should trigger warning"
1001        );
1002        assert!(warnings[0].message.contains("2 blank lines below"));
1003    }
1004
1005    #[test]
1006    fn test_scalar_configuration_still_works() {
1007        use md022_config::HeadingLevelConfig;
1008
1009        // Ensure scalar configuration still works (backward compatibility)
1010        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1011            lines_above: HeadingLevelConfig::scalar(2),
1012            lines_below: HeadingLevelConfig::scalar(2),
1013            allowed_at_start: false,
1014        });
1015
1016        let content = "Text\n# H1\nContent\n## H2\nContent";
1017        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1018        let warnings = rule.check(&ctx).unwrap();
1019
1020        // All headings should need 2 blanks above and below
1021        assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1022    }
1023
1024    #[test]
1025    fn test_unlimited_configuration_skips_requirements() {
1026        use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1027
1028        // H1 can have any number of blank lines above/below; others require defaults
1029        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1030            lines_above: HeadingLevelConfig::per_level_requirements([
1031                HeadingBlankRequirement::unlimited(),
1032                HeadingBlankRequirement::limited(1),
1033                HeadingBlankRequirement::limited(1),
1034                HeadingBlankRequirement::limited(1),
1035                HeadingBlankRequirement::limited(1),
1036                HeadingBlankRequirement::limited(1),
1037            ]),
1038            lines_below: HeadingLevelConfig::per_level_requirements([
1039                HeadingBlankRequirement::unlimited(),
1040                HeadingBlankRequirement::limited(1),
1041                HeadingBlankRequirement::limited(1),
1042                HeadingBlankRequirement::limited(1),
1043                HeadingBlankRequirement::limited(1),
1044                HeadingBlankRequirement::limited(1),
1045            ]),
1046            allowed_at_start: false,
1047        });
1048
1049        let content = "# H1\nParagraph\n## H2\nParagraph";
1050        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1051        let warnings = rule.check(&ctx).unwrap();
1052
1053        // H1 has no blanks above/below but is unlimited; H2 should get violations
1054        assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1055        assert!(
1056            warnings.iter().all(|w| w.line >= 3),
1057            "Warnings should target later headings"
1058        );
1059
1060        // Fixing should insert blanks around H2 but leave H1 untouched
1061        let fixed = rule.fix(&ctx).unwrap();
1062        assert!(
1063            fixed.starts_with("# H1\nParagraph\n\n## H2"),
1064            "H1 should remain unchanged"
1065        );
1066    }
1067
1068    #[test]
1069    fn test_html_comment_transparency() {
1070        // HTML comments are transparent for blank line counting
1071        // A heading following a blank line + HTML comment should be valid
1072        // Verified with markdownlint: no MD022 warning for this pattern
1073        let rule = MD022BlanksAroundHeadings::default();
1074
1075        // Pattern: content, blank line, HTML comment, heading
1076        // The blank line before the HTML comment counts for the heading
1077        let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1078        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1079        let warnings = rule.check(&ctx).unwrap();
1080        assert!(
1081            warnings.is_empty(),
1082            "HTML comment is transparent - blank line above it counts for heading"
1083        );
1084
1085        // Multi-line HTML comment is also transparent
1086        let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1087        let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1088        let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1089        assert!(
1090            warnings_multiline.is_empty(),
1091            "Multi-line HTML comment is also transparent"
1092        );
1093    }
1094
1095    #[test]
1096    fn test_frontmatter_transparency() {
1097        // Frontmatter is transparent for MD022 - heading can appear immediately after
1098        // Verified with markdownlint: no MD022 warning for heading after frontmatter
1099        let rule = MD022BlanksAroundHeadings::default();
1100
1101        // Heading immediately after frontmatter closing ---
1102        let content = "---\ntitle: Test\n---\n# First heading";
1103        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1104        let warnings = rule.check(&ctx).unwrap();
1105        assert!(
1106            warnings.is_empty(),
1107            "Frontmatter is transparent - heading can appear immediately after"
1108        );
1109
1110        // Heading with blank line after frontmatter is also valid
1111        let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1112        let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1113        let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1114        assert!(
1115            warnings_with_blank.is_empty(),
1116            "Heading with blank line after frontmatter should also be valid"
1117        );
1118
1119        // TOML frontmatter (+++...+++) is also transparent
1120        let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1121        let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1122        let warnings_toml = rule.check(&ctx_toml).unwrap();
1123        assert!(
1124            warnings_toml.is_empty(),
1125            "TOML frontmatter is also transparent for MD022"
1126        );
1127    }
1128
1129    #[test]
1130    fn test_horizontal_rule_not_treated_as_frontmatter() {
1131        // Issue #238: Horizontal rules (---) should NOT be treated as frontmatter.
1132        // A heading after a horizontal rule MUST have a blank line above it.
1133        let rule = MD022BlanksAroundHeadings::default();
1134
1135        // Case 1: Heading immediately after horizontal rule - SHOULD warn
1136        let content = "Some content\n\n---\n# Heading after HR";
1137        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1138        let warnings = rule.check(&ctx).unwrap();
1139        assert!(
1140            !warnings.is_empty(),
1141            "Heading after horizontal rule without blank line SHOULD trigger MD022"
1142        );
1143        assert!(
1144            warnings.iter().any(|w| w.line == 4),
1145            "Warning should be on line 4 (the heading line)"
1146        );
1147
1148        // Case 2: Heading with blank line after HR - should NOT warn
1149        let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1150        let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1151        let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1152        assert!(
1153            warnings_with_blank.is_empty(),
1154            "Heading with blank line after HR should not trigger MD022"
1155        );
1156
1157        // Case 3: HR at start of document followed by heading - SHOULD warn
1158        let content_hr_start = "---\n# Heading";
1159        let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1160        let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1161        assert!(
1162            !warnings_hr_start.is_empty(),
1163            "Heading after HR at document start SHOULD trigger MD022"
1164        );
1165
1166        // Case 4: Multiple HRs then heading - SHOULD warn
1167        let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1168        let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1169        let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1170        assert!(
1171            !warnings_multi_hr.is_empty(),
1172            "Heading after multiple HRs without blank line SHOULD trigger MD022"
1173        );
1174    }
1175
1176    #[test]
1177    fn test_all_hr_styles_require_blank_before_heading() {
1178        // CommonMark defines HRs as 3+ of -, *, or _ with optional spaces between
1179        let rule = MD022BlanksAroundHeadings::default();
1180
1181        // All valid HR styles that should trigger MD022 when followed by heading without blank
1182        let hr_styles = [
1183            "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1184            "-  -  -", // Multiple spaces between
1185            "  ---",   // 2 spaces indent (valid per CommonMark)
1186            "   ---",  // 3 spaces indent (valid per CommonMark)
1187        ];
1188
1189        for hr in hr_styles {
1190            let content = format!("Content\n\n{hr}\n# Heading");
1191            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1192            let warnings = rule.check(&ctx).unwrap();
1193            assert!(
1194                !warnings.is_empty(),
1195                "HR style '{hr}' followed by heading should trigger MD022"
1196            );
1197        }
1198    }
1199
1200    #[test]
1201    fn test_setext_heading_after_hr() {
1202        // Setext headings after HR should also require blank line
1203        let rule = MD022BlanksAroundHeadings::default();
1204
1205        // Setext h1 after HR without blank - SHOULD warn
1206        let content = "Content\n\n---\nHeading\n======";
1207        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1208        let warnings = rule.check(&ctx).unwrap();
1209        assert!(
1210            !warnings.is_empty(),
1211            "Setext heading after HR without blank should trigger MD022"
1212        );
1213
1214        // Setext h2 after HR without blank - SHOULD warn
1215        let content_h2 = "Content\n\n---\nHeading\n------";
1216        let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1217        let warnings_h2 = rule.check(&ctx_h2).unwrap();
1218        assert!(
1219            !warnings_h2.is_empty(),
1220            "Setext h2 after HR without blank should trigger MD022"
1221        );
1222
1223        // With blank line - should NOT warn
1224        let content_ok = "Content\n\n---\n\nHeading\n======";
1225        let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1226        let warnings_ok = rule.check(&ctx_ok).unwrap();
1227        assert!(
1228            warnings_ok.is_empty(),
1229            "Setext heading with blank after HR should not warn"
1230        );
1231    }
1232
1233    #[test]
1234    fn test_hr_in_code_block_not_treated_as_hr() {
1235        // HR syntax inside code blocks should be ignored
1236        let rule = MD022BlanksAroundHeadings::default();
1237
1238        // HR inside fenced code block - heading after code block needs blank line check
1239        // but the "---" inside is NOT an HR
1240        let content = "```\n---\n```\n# Heading";
1241        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242        let warnings = rule.check(&ctx).unwrap();
1243        // The heading is after a code block fence, not after an HR
1244        // This tests that we don't confuse code block content with HRs
1245        assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1246
1247        // With blank after code block - should be fine
1248        let content_ok = "```\n---\n```\n\n# Heading";
1249        let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1250        let warnings_ok = rule.check(&ctx_ok).unwrap();
1251        assert!(
1252            warnings_ok.is_empty(),
1253            "Heading with blank after code block should not warn"
1254        );
1255    }
1256
1257    #[test]
1258    fn test_hr_in_html_comment_not_treated_as_hr() {
1259        // HR syntax inside HTML comments should be ignored
1260        let rule = MD022BlanksAroundHeadings::default();
1261
1262        // "---" inside HTML comment is NOT an HR
1263        let content = "<!-- \n---\n -->\n# Heading";
1264        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1265        let warnings = rule.check(&ctx).unwrap();
1266        // HTML comments are transparent, so heading after comment at doc start is OK
1267        assert!(
1268            warnings.is_empty(),
1269            "HR inside HTML comment should be ignored - heading after comment is OK"
1270        );
1271    }
1272
1273    #[test]
1274    fn test_invalid_hr_not_triggering() {
1275        // These should NOT be recognized as HRs per CommonMark
1276        let rule = MD022BlanksAroundHeadings::default();
1277
1278        let invalid_hrs = [
1279            "    ---", // 4+ spaces is code block, not HR
1280            "\t---",   // Tab indent makes it code block
1281            "--",      // Only 2 dashes
1282            "**",      // Only 2 asterisks
1283            "__",      // Only 2 underscores
1284            "-*-",     // Mixed characters
1285            "---a",    // Extra character at end
1286            "a---",    // Extra character at start
1287        ];
1288
1289        for invalid in invalid_hrs {
1290            // These are NOT HRs, so if followed by heading, the heading behavior depends
1291            // on what the content actually is (code block, paragraph, etc.)
1292            let content = format!("Content\n\n{invalid}\n# Heading");
1293            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1294            // We're just verifying the HR detection is correct
1295            // The actual warning behavior depends on what the "invalid HR" is parsed as
1296            let _ = rule.check(&ctx);
1297        }
1298    }
1299
1300    #[test]
1301    fn test_frontmatter_vs_horizontal_rule_distinction() {
1302        // Ensure we correctly distinguish between frontmatter delimiters and standalone HRs
1303        let rule = MD022BlanksAroundHeadings::default();
1304
1305        // Frontmatter followed by content, then HR, then heading
1306        // The HR here is NOT frontmatter, so heading needs blank line
1307        let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1308        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1309        let warnings = rule.check(&ctx).unwrap();
1310        assert!(
1311            !warnings.is_empty(),
1312            "HR after frontmatter content should still require blank line before heading"
1313        );
1314
1315        // Same but with blank line after HR - should be fine
1316        let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1317        let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1318        let warnings_ok = rule.check(&ctx_ok).unwrap();
1319        assert!(
1320            warnings_ok.is_empty(),
1321            "HR with blank line before heading should not warn"
1322        );
1323    }
1324}