Skip to main content

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::kramdown_utils::is_kramdown_block_attribute;
7use crate::utils::quarto_divs;
8use crate::utils::range_utils::calculate_heading_range;
9use toml;
10
11mod md022_config;
12use md022_config::MD022Config;
13
14///
15/// This rule enforces consistent spacing around headings to improve document readability
16/// and visual structure.
17///
18/// ## Purpose
19///
20/// - **Readability**: Blank lines create visual separation, making headings stand out
21/// - **Parsing**: Many Markdown parsers require blank lines around headings for proper rendering
22/// - **Consistency**: Creates a uniform document style throughout
23/// - **Focus**: Helps readers identify and focus on section transitions
24///
25/// ## Configuration Options
26///
27/// The rule supports customizing the number of blank lines required:
28///
29/// ```yaml
30/// MD022:
31///   lines_above: 1  # Number of blank lines required above headings (default: 1)
32///   lines_below: 1  # Number of blank lines required below headings (default: 1)
33/// ```
34///
35/// ## Examples
36///
37/// ### Correct (with default configuration)
38///
39/// ```markdown
40/// Regular paragraph text.
41///
42/// # Heading 1
43///
44/// Content under heading 1.
45///
46/// ## Heading 2
47///
48/// More content here.
49/// ```
50///
51/// ### Incorrect (with default configuration)
52///
53/// ```markdown
54/// Regular paragraph text.
55/// # Heading 1
56/// Content under heading 1.
57/// ## Heading 2
58/// More content here.
59/// ```
60///
61/// ## Special Cases
62///
63/// This rule handles several special cases:
64///
65/// - **First Heading**: The first heading in a document doesn't require blank lines above
66///   if it appears at the very start of the document
67/// - **Front Matter**: YAML front matter is detected and skipped
68/// - **Code Blocks**: Headings inside code blocks are ignored
69/// - **Document Start/End**: Adjusts requirements for headings at the beginning or end of a document
70///
71/// ## Fix Behavior
72///
73/// When applying automatic fixes, this rule:
74/// - Adds the required number of blank lines above headings
75/// - Adds the required number of blank lines below headings
76/// - Preserves document structure and existing content
77///
78/// ## Performance Considerations
79///
80/// The rule is optimized for performance with:
81/// - Efficient line counting algorithms
82/// - Proper handling of front matter
83/// - Smart code block detection
84///
85#[derive(Clone, Default)]
86pub struct MD022BlanksAroundHeadings {
87    config: MD022Config,
88}
89
90impl MD022BlanksAroundHeadings {
91    /// Create a new instance of the rule with default values:
92    /// lines_above = 1, lines_below = 1
93    pub fn new() -> Self {
94        Self {
95            config: MD022Config::default(),
96        }
97    }
98
99    /// Create with custom numbers of blank lines (applies to all heading levels)
100    pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
101        use md022_config::HeadingLevelConfig;
102        Self {
103            config: MD022Config {
104                lines_above: HeadingLevelConfig::scalar(lines_above),
105                lines_below: HeadingLevelConfig::scalar(lines_below),
106                allowed_at_start: true,
107            },
108        }
109    }
110
111    pub fn from_config_struct(config: MD022Config) -> Self {
112        Self { config }
113    }
114
115    /// Fix a document by adding appropriate blank lines around headings
116    fn _fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
117        // Content is normalized to LF at I/O boundary
118        let line_ending = "\n";
119        let had_trailing_newline = ctx.content.ends_with('\n');
120        let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
121        let mut result = Vec::new();
122        let mut skip_count: usize = 0;
123
124        let heading_at_start_idx = {
125            let mut found_non_transparent = false;
126            ctx.lines.iter().enumerate().find_map(|(i, line)| {
127                // Only count valid headings (skip malformed ones like `#NoSpace`)
128                if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
129                    Some(i)
130                } else {
131                    // HTML comments and blank lines are "transparent" - they don't count as content
132                    // that would prevent a heading from being "at document start"
133                    if !line.is_blank && !line.in_html_comment {
134                        let trimmed = line.content(ctx.content).trim();
135                        // Check for single-line HTML comments too
136                        if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
137                            // Transparent - HTML comment
138                        } else if is_quarto && (quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed))
139                        {
140                            // Transparent - Quarto div marker in Quarto flavor
141                        } else {
142                            found_non_transparent = true;
143                        }
144                    }
145                    None
146                }
147            })
148        };
149
150        for (i, line_info) in ctx.lines.iter().enumerate() {
151            if skip_count > 0 {
152                skip_count -= 1;
153                continue;
154            }
155            let line = line_info.content(ctx.content);
156
157            if line_info.in_code_block {
158                result.push(line.to_string());
159                continue;
160            }
161
162            // Check if it's a heading
163            if let Some(heading) = &line_info.heading {
164                // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
165                if !heading.is_valid {
166                    result.push(line.to_string());
167                    continue;
168                }
169
170                // This is a heading line (ATX or Setext content)
171                let is_first_heading = Some(i) == heading_at_start_idx;
172                let heading_level = heading.level as usize;
173
174                // Count existing blank lines above in the result, skipping HTML comments, IAL, and Quarto div markers
175                let mut blank_lines_above = 0;
176                let mut check_idx = result.len();
177                while check_idx > 0 {
178                    let prev_line = &result[check_idx - 1];
179                    let trimmed = prev_line.trim();
180                    if trimmed.is_empty() {
181                        blank_lines_above += 1;
182                        check_idx -= 1;
183                    } else if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
184                        // Skip HTML comments - they are transparent for blank line counting
185                        check_idx -= 1;
186                    } else if is_kramdown_block_attribute(trimmed) {
187                        // Skip kramdown IAL - they are attached to headings and transparent
188                        check_idx -= 1;
189                    } else if is_quarto && (quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed)) {
190                        // Skip Quarto div markers - they are transparent for blank line counting in Quarto flavor
191                        check_idx -= 1;
192                    } else {
193                        break;
194                    }
195                }
196
197                // Determine how many blank lines we need above
198                let requirement_above = self.config.lines_above.get_for_level(heading_level);
199                let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
200                    0
201                } else {
202                    requirement_above.required_count().unwrap_or(0)
203                };
204
205                // Add missing blank lines above if needed
206                while blank_lines_above < needed_blanks_above {
207                    result.push(String::new());
208                    blank_lines_above += 1;
209                }
210
211                // Add the heading line
212                result.push(line.to_string());
213
214                // Determine base index for checking lines below
215                let mut effective_end_idx = i;
216
217                // For Setext headings, also add the underline immediately
218                if matches!(
219                    heading.style,
220                    crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
221                ) {
222                    // Add the underline (next line)
223                    if i + 1 < ctx.lines.len() {
224                        result.push(ctx.lines[i + 1].content(ctx.content).to_string());
225                        skip_count += 1; // Skip the underline in the main loop
226                        effective_end_idx = i + 1;
227                    }
228                }
229
230                // Add any kramdown IAL lines that immediately follow the heading
231                // These are part of the heading element and should not be separated
232                let mut ial_count = 0;
233                while effective_end_idx + 1 < ctx.lines.len() {
234                    let next_line = &ctx.lines[effective_end_idx + 1];
235                    let next_trimmed = next_line.content(ctx.content).trim();
236                    if is_kramdown_block_attribute(next_trimmed) {
237                        result.push(next_trimmed.to_string());
238                        effective_end_idx += 1;
239                        ial_count += 1;
240                    } else {
241                        break;
242                    }
243                }
244
245                // Now check blank lines below the heading (including underline and IAL)
246                let mut blank_lines_below = 0;
247                let mut next_content_line_idx = None;
248                for j in (effective_end_idx + 1)..ctx.lines.len() {
249                    if ctx.lines[j].is_blank {
250                        blank_lines_below += 1;
251                    } else {
252                        next_content_line_idx = Some(j);
253                        break;
254                    }
255                }
256
257                // Check if the next non-blank line is special (code fence or list item)
258                let next_is_special = if let Some(idx) = next_content_line_idx {
259                    let next_line = &ctx.lines[idx];
260                    next_line.list_item.is_some() || {
261                        let trimmed = next_line.content(ctx.content).trim();
262                        (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
263                            && (trimmed.len() == 3
264                                || (trimmed.len() > 3
265                                    && trimmed
266                                        .chars()
267                                        .nth(3)
268                                        .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
269                    }
270                } else {
271                    false
272                };
273
274                // Add missing blank lines below if needed
275                let requirement_below = self.config.lines_below.get_for_level(heading_level);
276                let needed_blanks_below = if next_is_special {
277                    0
278                } else {
279                    requirement_below.required_count().unwrap_or(0)
280                };
281                if blank_lines_below < needed_blanks_below {
282                    for _ in 0..(needed_blanks_below - blank_lines_below) {
283                        result.push(String::new());
284                    }
285                }
286
287                // Skip the IAL lines in the main loop since we already added them
288                skip_count += ial_count;
289            } else {
290                // Regular line - just add it
291                result.push(line.to_string());
292            }
293        }
294
295        let joined = result.join(line_ending);
296
297        // Preserve original trailing newline behavior
298        // Content is normalized to LF at I/O boundary
299        if had_trailing_newline && !joined.ends_with('\n') {
300            format!("{joined}{line_ending}")
301        } else if !had_trailing_newline && joined.ends_with('\n') {
302            // Remove trailing newline if original didn't have one
303            joined[..joined.len() - 1].to_string()
304        } else {
305            joined
306        }
307    }
308}
309
310impl Rule for MD022BlanksAroundHeadings {
311    fn name(&self) -> &'static str {
312        "MD022"
313    }
314
315    fn description(&self) -> &'static str {
316        "Headings should be surrounded by blank lines"
317    }
318
319    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
320        let mut result = Vec::new();
321
322        // Skip if empty document
323        if ctx.lines.is_empty() {
324            return Ok(result);
325        }
326
327        // Content is normalized to LF at I/O boundary
328        let line_ending = "\n";
329        let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
330
331        let heading_at_start_idx = {
332            let mut found_non_transparent = false;
333            ctx.lines.iter().enumerate().find_map(|(i, line)| {
334                // Only count valid headings (skip malformed ones like `#NoSpace`)
335                if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
336                    Some(i)
337                } else {
338                    // HTML comments and blank lines are "transparent" - they don't count as content
339                    // that would prevent a heading from being "at document start"
340                    if !line.is_blank && !line.in_html_comment {
341                        let trimmed = line.content(ctx.content).trim();
342                        // Check for single-line HTML comments too
343                        if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
344                            // Transparent - HTML comment
345                        } else if is_quarto && (quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed))
346                        {
347                            // Transparent - Quarto div marker in Quarto flavor
348                        } else {
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            // Skip headings inside PyMdown blocks (/// ... ///) - MkDocs flavor only
368            if line_info.in_pymdown_block {
369                continue;
370            }
371
372            let heading = line_info.heading.as_ref().unwrap();
373
374            // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
375            if !heading.is_valid {
376                continue;
377            }
378
379            let heading_level = heading.level as usize;
380
381            // Note: Setext underline lines have heading=None, so they're already
382            // skipped by the check at line 351. No additional check needed here.
383
384            processed_headings.insert(line_num);
385
386            // Check if this heading is at document start
387            let is_first_heading = Some(line_num) == heading_at_start_idx;
388
389            // Get configured blank line requirements for this heading level
390            let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
391            let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
392
393            // Count blank lines above if needed
394            let should_check_above =
395                required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
396            if should_check_above {
397                let mut blank_lines_above = 0;
398                let mut hit_frontmatter_end = false;
399                for j in (0..line_num).rev() {
400                    let line_content = ctx.lines[j].content(ctx.content);
401                    let trimmed = line_content.trim();
402                    if ctx.lines[j].is_blank {
403                        blank_lines_above += 1;
404                    } else if ctx.lines[j].in_html_comment || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
405                    {
406                        // Skip HTML comments - they are transparent for blank line counting
407                        continue;
408                    } else if is_kramdown_block_attribute(trimmed) {
409                        // Skip kramdown IAL - they are attached to headings and transparent for blank line counting
410                        continue;
411                    } else if is_quarto && (quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed)) {
412                        // Skip Quarto div markers - they are transparent for blank line counting in Quarto flavor
413                        continue;
414                    } else if ctx.lines[j].in_front_matter {
415                        // Skip frontmatter - first heading after frontmatter doesn't need blank line above
416                        // Note: We only check in_front_matter flag, NOT the string "---", because
417                        // a standalone "---" is a horizontal rule and should NOT exempt headings
418                        // from requiring blank lines above
419                        hit_frontmatter_end = true;
420                        break;
421                    } else {
422                        break;
423                    }
424                }
425                let required = required_above_count.unwrap();
426                if !hit_frontmatter_end && blank_lines_above < required {
427                    let needed_blanks = required - blank_lines_above;
428                    heading_violations.push((line_num, "above", needed_blanks, heading_level));
429                }
430            }
431
432            // Determine the effective last line of the heading
433            let mut effective_last_line = if matches!(
434                heading.style,
435                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
436            ) {
437                line_num + 1 // For Setext, include the underline
438            } else {
439                line_num
440            };
441
442            // Extend effective_last_line to include any kramdown IAL lines immediately following
443            // IAL lines like `{: .class #id}` are part of the heading element
444            while effective_last_line + 1 < ctx.lines.len() {
445                let next_line = &ctx.lines[effective_last_line + 1];
446                let next_trimmed = next_line.content(ctx.content).trim();
447                if is_kramdown_block_attribute(next_trimmed) {
448                    effective_last_line += 1;
449                } else {
450                    break;
451                }
452            }
453
454            // Check blank lines below
455            if effective_last_line < ctx.lines.len() - 1 {
456                // Find next non-blank line, skipping transparent elements (blank lines, HTML comments, Quarto div markers)
457                let mut next_non_blank_idx = effective_last_line + 1;
458                while next_non_blank_idx < ctx.lines.len() {
459                    let check_line = &ctx.lines[next_non_blank_idx];
460                    let check_trimmed = check_line.content(ctx.content).trim();
461                    if check_line.is_blank {
462                        next_non_blank_idx += 1;
463                    } else if check_line.in_html_comment
464                        || (check_trimmed.starts_with("<!--") && check_trimmed.ends_with("-->"))
465                    {
466                        // Skip HTML comments - they are transparent for blank line counting
467                        next_non_blank_idx += 1;
468                    } else if is_quarto
469                        && (quarto_divs::is_div_open(check_trimmed) || quarto_divs::is_div_close(check_trimmed))
470                    {
471                        // Skip Quarto div markers - they are transparent for blank line counting in Quarto flavor
472                        next_non_blank_idx += 1;
473                    } else {
474                        break;
475                    }
476                }
477
478                // If we've reached end of document (after skipping transparent elements), no blank needed
479                if next_non_blank_idx >= ctx.lines.len() {
480                    // End of document - no blank line needed after heading
481                    continue;
482                }
483
484                // Check if next line is a code fence or list item
485                let next_line_is_special = {
486                    let next_line = &ctx.lines[next_non_blank_idx];
487                    let next_trimmed = next_line.content(ctx.content).trim();
488
489                    // Check for code fence
490                    let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
491                        && (next_trimmed.len() == 3
492                            || (next_trimmed.len() > 3
493                                && next_trimmed
494                                    .chars()
495                                    .nth(3)
496                                    .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
497
498                    // Check for list item
499                    let is_list_item = next_line.list_item.is_some();
500
501                    is_code_fence || is_list_item
502                };
503
504                // Only generate warning if next line is NOT a code fence or list item
505                if !next_line_is_special && let Some(required) = required_below_count {
506                    // Count blank lines below (counting only blank lines, not skipped transparent lines)
507                    let mut blank_lines_below = 0;
508                    for k in (effective_last_line + 1)..next_non_blank_idx {
509                        if ctx.lines[k].is_blank {
510                            blank_lines_below += 1;
511                        }
512                    }
513
514                    if blank_lines_below < required {
515                        let needed_blanks = required - blank_lines_below;
516                        heading_violations.push((line_num, "below", needed_blanks, heading_level));
517                    }
518                }
519            }
520        }
521
522        // Generate warnings for all violations
523        for (heading_line, position, needed_blanks, heading_level) in heading_violations {
524            let heading_display_line = heading_line + 1; // 1-indexed for display
525            let line_info = &ctx.lines[heading_line];
526
527            // Calculate precise character range for the heading
528            let (start_line, start_col, end_line, end_col) =
529                calculate_heading_range(heading_display_line, line_info.content(ctx.content));
530
531            let required_above_count = self
532                .config
533                .lines_above
534                .get_for_level(heading_level)
535                .required_count()
536                .expect("Violations only generated for limited 'above' requirements");
537            let required_below_count = self
538                .config
539                .lines_below
540                .get_for_level(heading_level)
541                .required_count()
542                .expect("Violations only generated for limited 'below' requirements");
543
544            let (message, insertion_point) = match position {
545                "above" => (
546                    format!(
547                        "Expected {} blank {} above heading",
548                        required_above_count,
549                        if required_above_count == 1 { "line" } else { "lines" }
550                    ),
551                    heading_line, // Insert before the heading line
552                ),
553                "below" => {
554                    // For Setext headings, insert after the underline
555                    let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
556                        matches!(
557                            h.style,
558                            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
559                        )
560                    }) {
561                        heading_line + 2
562                    } else {
563                        heading_line + 1
564                    };
565
566                    (
567                        format!(
568                            "Expected {} blank {} below heading",
569                            required_below_count,
570                            if required_below_count == 1 { "line" } else { "lines" }
571                        ),
572                        insert_after,
573                    )
574                }
575                _ => continue,
576            };
577
578            // Calculate byte range for insertion
579            let byte_range = if insertion_point == 0 && position == "above" {
580                // Insert at beginning of document (only for "above" case at line 0)
581                0..0
582            } else if position == "above" && insertion_point > 0 {
583                // For "above", insert at the start of the heading line
584                ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
585            } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
586                // For "below", insert after the line
587                let line_idx = insertion_point - 1;
588                let line_end_offset = if line_idx + 1 < ctx.lines.len() {
589                    ctx.lines[line_idx + 1].byte_offset
590                } else {
591                    ctx.content.len()
592                };
593                line_end_offset..line_end_offset
594            } else {
595                // Insert at end of file
596                let content_len = ctx.content.len();
597                content_len..content_len
598            };
599
600            result.push(LintWarning {
601                rule_name: Some(self.name().to_string()),
602                message,
603                line: start_line,
604                column: start_col,
605                end_line,
606                end_column: end_col,
607                severity: Severity::Warning,
608                fix: Some(Fix {
609                    range: byte_range,
610                    replacement: line_ending.repeat(needed_blanks),
611                }),
612            });
613        }
614
615        Ok(result)
616    }
617
618    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
619        if ctx.content.is_empty() {
620            return Ok(ctx.content.to_string());
621        }
622
623        // Use a consolidated fix that avoids adding multiple blank lines
624        let fixed = self._fix_content(ctx);
625
626        Ok(fixed)
627    }
628
629    /// Get the category of this rule for selective processing
630    fn category(&self) -> RuleCategory {
631        RuleCategory::Heading
632    }
633
634    /// Check if this rule should be skipped
635    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
636        // Fast path: check if document likely has headings
637        if ctx.content.is_empty() || !ctx.likely_has_headings() {
638            return true;
639        }
640        // Verify headings actually exist
641        ctx.lines.iter().all(|line| line.heading.is_none())
642    }
643
644    fn as_any(&self) -> &dyn std::any::Any {
645        self
646    }
647
648    fn default_config_section(&self) -> Option<(String, toml::Value)> {
649        let default_config = MD022Config::default();
650        let json_value = serde_json::to_value(&default_config).ok()?;
651        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
652
653        if let toml::Value::Table(table) = toml_value {
654            if !table.is_empty() {
655                Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
656            } else {
657                None
658            }
659        } else {
660            None
661        }
662    }
663
664    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
665    where
666        Self: Sized,
667    {
668        let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
669        Box::new(Self::from_config_struct(rule_config))
670    }
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use crate::lint_context::LintContext;
677
678    #[test]
679    fn test_valid_headings() {
680        let rule = MD022BlanksAroundHeadings::default();
681        let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
682        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683        let result = rule.check(&ctx).unwrap();
684        assert!(result.is_empty());
685    }
686
687    #[test]
688    fn test_missing_blank_above() {
689        let rule = MD022BlanksAroundHeadings::default();
690        let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
691        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692        let result = rule.check(&ctx).unwrap();
693        assert_eq!(result.len(), 0); // No warning for first heading
694
695        let fixed = rule.fix(&ctx).unwrap();
696
697        // Test for the ability to handle the content without breaking it
698        // Don't check for exact string equality which may break with implementation changes
699        assert!(fixed.contains("# Heading 1"));
700        assert!(fixed.contains("Some content."));
701        assert!(fixed.contains("## Heading 2"));
702        assert!(fixed.contains("More content."));
703    }
704
705    #[test]
706    fn test_missing_blank_below() {
707        let rule = MD022BlanksAroundHeadings::default();
708        let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
709        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
710        let result = rule.check(&ctx).unwrap();
711        assert_eq!(result.len(), 1);
712        assert_eq!(result[0].line, 2);
713
714        // Test the fix
715        let fixed = rule.fix(&ctx).unwrap();
716        assert!(fixed.contains("# Heading 1\n\nSome content"));
717    }
718
719    #[test]
720    fn test_missing_blank_above_and_below() {
721        let rule = MD022BlanksAroundHeadings::default();
722        let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
723        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
724        let result = rule.check(&ctx).unwrap();
725        assert_eq!(result.len(), 3); // Missing blanks: below first heading, above second heading, below second heading
726
727        // Test the fix
728        let fixed = rule.fix(&ctx).unwrap();
729        assert!(fixed.contains("# Heading 1\n\nSome content"));
730        assert!(fixed.contains("Some content.\n\n## Heading 2"));
731        assert!(fixed.contains("## Heading 2\n\nMore content"));
732    }
733
734    #[test]
735    fn test_fix_headings() {
736        let rule = MD022BlanksAroundHeadings::default();
737        let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
738        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739        let result = rule.fix(&ctx).unwrap();
740
741        let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
742        assert_eq!(result, expected);
743    }
744
745    #[test]
746    fn test_consecutive_headings_pattern() {
747        let rule = MD022BlanksAroundHeadings::default();
748        let content = "# Heading 1\n## Heading 2\n### Heading 3";
749        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750        let result = rule.fix(&ctx).unwrap();
751
752        // Using more specific assertions to check the structure
753        let lines: Vec<&str> = result.lines().collect();
754        assert!(!lines.is_empty());
755
756        // Find the positions of the headings
757        let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
758        let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
759        let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
760
761        // Verify blank lines between headings
762        assert!(
763            h2_pos > h1_pos + 1,
764            "Should have at least one blank line after first heading"
765        );
766        assert!(
767            h3_pos > h2_pos + 1,
768            "Should have at least one blank line after second heading"
769        );
770
771        // Verify there's a blank line between h1 and h2
772        assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
773
774        // Verify there's a blank line between h2 and h3
775        assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
776    }
777
778    #[test]
779    fn test_blanks_around_setext_headings() {
780        let rule = MD022BlanksAroundHeadings::default();
781        let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
782        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783        let result = rule.fix(&ctx).unwrap();
784
785        // Check that the fix follows requirements without being too rigid about the exact output format
786        let lines: Vec<&str> = result.lines().collect();
787
788        // Verify key elements are present
789        assert!(result.contains("Heading 1"));
790        assert!(result.contains("========="));
791        assert!(result.contains("Some content."));
792        assert!(result.contains("Heading 2"));
793        assert!(result.contains("---------"));
794        assert!(result.contains("More content."));
795
796        // Verify structure ensures blank lines are added after headings
797        let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
798        let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
799        assert!(
800            some_content_idx > heading1_marker_idx + 1,
801            "Should have a blank line after the first heading"
802        );
803
804        let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
805        let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
806        assert!(
807            more_content_idx > heading2_marker_idx + 1,
808            "Should have a blank line after the second heading"
809        );
810
811        // Verify that the fixed content has no warnings
812        let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
813        let fixed_warnings = rule.check(&fixed_ctx).unwrap();
814        assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
815    }
816
817    #[test]
818    fn test_fix_specific_blank_line_cases() {
819        let rule = MD022BlanksAroundHeadings::default();
820
821        // Case 1: Testing consecutive headings
822        let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
823        let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
824        let result1 = rule.fix(&ctx1).unwrap();
825        // Verify structure rather than exact content as the fix implementation may vary
826        assert!(result1.contains("# Heading 1"));
827        assert!(result1.contains("## Heading 2"));
828        assert!(result1.contains("### Heading 3"));
829        // Ensure each heading has a blank line after it
830        let lines: Vec<&str> = result1.lines().collect();
831        let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
832        let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
833        assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
834        assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
835
836        // Case 2: Headings with content
837        let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
838        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
839        let result2 = rule.fix(&ctx2).unwrap();
840        // Verify structure
841        assert!(result2.contains("# Heading 1"));
842        assert!(result2.contains("Content under heading 1"));
843        assert!(result2.contains("## Heading 2"));
844        // Check spacing
845        let lines2: Vec<&str> = result2.lines().collect();
846        let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
847        let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
848        assert!(
849            lines2[h1_pos2 + 1].trim().is_empty(),
850            "Should have a blank line after heading 1"
851        );
852
853        // Case 3: Multiple consecutive headings with blank lines preserved
854        let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
855        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
856        let result3 = rule.fix(&ctx3).unwrap();
857        // Just verify it doesn't crash and properly formats headings
858        assert!(result3.contains("# Heading 1"));
859        assert!(result3.contains("## Heading 2"));
860        assert!(result3.contains("### Heading 3"));
861        assert!(result3.contains("Content"));
862    }
863
864    #[test]
865    fn test_fix_preserves_existing_blank_lines() {
866        let rule = MD022BlanksAroundHeadings::new();
867        let content = "# Title
868
869## Section 1
870
871Content here.
872
873## Section 2
874
875More content.
876### Missing Blank Above
877
878Even more content.
879
880## Section 3
881
882Final content.";
883
884        let expected = "# Title
885
886## Section 1
887
888Content here.
889
890## Section 2
891
892More content.
893
894### Missing Blank Above
895
896Even more content.
897
898## Section 3
899
900Final content.";
901
902        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
903        let result = rule._fix_content(&ctx);
904        assert_eq!(
905            result, expected,
906            "Fix should only add missing blank lines, never remove existing ones"
907        );
908    }
909
910    #[test]
911    fn test_fix_preserves_trailing_newline() {
912        let rule = MD022BlanksAroundHeadings::new();
913
914        // Test with trailing newline
915        let content_with_newline = "# Title\nContent here.\n";
916        let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
917        let result = rule.fix(&ctx).unwrap();
918        assert!(result.ends_with('\n'), "Should preserve trailing newline");
919
920        // Test without trailing newline
921        let content_without_newline = "# Title\nContent here.";
922        let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
923        let result = rule.fix(&ctx).unwrap();
924        assert!(
925            !result.ends_with('\n'),
926            "Should not add trailing newline if original didn't have one"
927        );
928    }
929
930    #[test]
931    fn test_fix_does_not_add_blank_lines_before_lists() {
932        let rule = MD022BlanksAroundHeadings::new();
933        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.";
934
935        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.";
936
937        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938        let result = rule._fix_content(&ctx);
939        assert_eq!(result, expected, "Fix should not add blank lines before lists");
940    }
941
942    #[test]
943    fn test_per_level_configuration_no_blank_above_h1() {
944        use md022_config::HeadingLevelConfig;
945
946        // Configure: no blank above H1, 1 blank above H2-H6
947        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
948            lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
949            lines_below: HeadingLevelConfig::scalar(1),
950            allowed_at_start: false, // Disable special handling for first heading
951        });
952
953        // H1 without blank above should be OK
954        let content = "Some text\n# Heading 1\n\nMore text";
955        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956        let warnings = rule.check(&ctx).unwrap();
957        assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
958
959        // H2 without blank above should trigger warning
960        let content = "Some text\n## Heading 2\n\nMore text";
961        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
962        let warnings = rule.check(&ctx).unwrap();
963        assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
964        assert!(warnings[0].message.contains("above"));
965    }
966
967    #[test]
968    fn test_per_level_configuration_different_requirements() {
969        use md022_config::HeadingLevelConfig;
970
971        // Configure: 0 blank above H1, 1 above H2-H3, 2 above H4-H6
972        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
973            lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
974            lines_below: HeadingLevelConfig::scalar(1),
975            allowed_at_start: false,
976        });
977
978        let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
979        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
980        let warnings = rule.check(&ctx).unwrap();
981
982        // Should have no warnings - all headings satisfy their level-specific requirements
983        assert_eq!(
984            warnings.len(),
985            0,
986            "All headings should satisfy level-specific requirements"
987        );
988    }
989
990    #[test]
991    fn test_per_level_configuration_violations() {
992        use md022_config::HeadingLevelConfig;
993
994        // Configure: H4 needs 2 blanks above
995        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
996            lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
997            lines_below: HeadingLevelConfig::scalar(1),
998            allowed_at_start: false,
999        });
1000
1001        // H4 with only 1 blank above should trigger warning
1002        let content = "Text\n\n#### Heading 4\n\nMore text";
1003        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004        let warnings = rule.check(&ctx).unwrap();
1005
1006        assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1007        assert!(warnings[0].message.contains("2 blank lines above"));
1008    }
1009
1010    #[test]
1011    fn test_per_level_fix_different_levels() {
1012        use md022_config::HeadingLevelConfig;
1013
1014        // Configure: 0 blank above H1, 1 above H2, 2 above H3+
1015        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1016            lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1017            lines_below: HeadingLevelConfig::scalar(1),
1018            allowed_at_start: false,
1019        });
1020
1021        let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1022        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1023        let fixed = rule.fix(&ctx).unwrap();
1024
1025        // Verify structure: H1 gets 0 blanks above, H2 gets 1, H3 gets 2
1026        assert!(fixed.contains("Text\n# H1\n\nContent"));
1027        assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1028        assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1029    }
1030
1031    #[test]
1032    fn test_per_level_below_configuration() {
1033        use md022_config::HeadingLevelConfig;
1034
1035        // Configure: different blank line requirements below headings
1036        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1037            lines_above: HeadingLevelConfig::scalar(1),
1038            lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), // H1 needs 2 blanks below
1039            allowed_at_start: true,
1040        });
1041
1042        // H1 with only 1 blank below should trigger warning
1043        let content = "# Heading 1\n\nSome text";
1044        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045        let warnings = rule.check(&ctx).unwrap();
1046
1047        assert_eq!(
1048            warnings.len(),
1049            1,
1050            "H1 with insufficient blanks below should trigger warning"
1051        );
1052        assert!(warnings[0].message.contains("2 blank lines below"));
1053    }
1054
1055    #[test]
1056    fn test_scalar_configuration_still_works() {
1057        use md022_config::HeadingLevelConfig;
1058
1059        // Ensure scalar configuration still works (backward compatibility)
1060        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1061            lines_above: HeadingLevelConfig::scalar(2),
1062            lines_below: HeadingLevelConfig::scalar(2),
1063            allowed_at_start: false,
1064        });
1065
1066        let content = "Text\n# H1\nContent\n## H2\nContent";
1067        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1068        let warnings = rule.check(&ctx).unwrap();
1069
1070        // All headings should need 2 blanks above and below
1071        assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1072    }
1073
1074    #[test]
1075    fn test_unlimited_configuration_skips_requirements() {
1076        use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1077
1078        // H1 can have any number of blank lines above/below; others require defaults
1079        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1080            lines_above: HeadingLevelConfig::per_level_requirements([
1081                HeadingBlankRequirement::unlimited(),
1082                HeadingBlankRequirement::limited(1),
1083                HeadingBlankRequirement::limited(1),
1084                HeadingBlankRequirement::limited(1),
1085                HeadingBlankRequirement::limited(1),
1086                HeadingBlankRequirement::limited(1),
1087            ]),
1088            lines_below: HeadingLevelConfig::per_level_requirements([
1089                HeadingBlankRequirement::unlimited(),
1090                HeadingBlankRequirement::limited(1),
1091                HeadingBlankRequirement::limited(1),
1092                HeadingBlankRequirement::limited(1),
1093                HeadingBlankRequirement::limited(1),
1094                HeadingBlankRequirement::limited(1),
1095            ]),
1096            allowed_at_start: false,
1097        });
1098
1099        let content = "# H1\nParagraph\n## H2\nParagraph";
1100        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1101        let warnings = rule.check(&ctx).unwrap();
1102
1103        // H1 has no blanks above/below but is unlimited; H2 should get violations
1104        assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1105        assert!(
1106            warnings.iter().all(|w| w.line >= 3),
1107            "Warnings should target later headings"
1108        );
1109
1110        // Fixing should insert blanks around H2 but leave H1 untouched
1111        let fixed = rule.fix(&ctx).unwrap();
1112        assert!(
1113            fixed.starts_with("# H1\nParagraph\n\n## H2"),
1114            "H1 should remain unchanged"
1115        );
1116    }
1117
1118    #[test]
1119    fn test_html_comment_transparency() {
1120        // HTML comments are transparent for blank line counting
1121        // A heading following a blank line + HTML comment should be valid
1122        // Verified with markdownlint: no MD022 warning for this pattern
1123        let rule = MD022BlanksAroundHeadings::default();
1124
1125        // Pattern: content, blank line, HTML comment, heading
1126        // The blank line before the HTML comment counts for the heading
1127        let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1128        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1129        let warnings = rule.check(&ctx).unwrap();
1130        assert!(
1131            warnings.is_empty(),
1132            "HTML comment is transparent - blank line above it counts for heading"
1133        );
1134
1135        // Multi-line HTML comment is also transparent
1136        let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1137        let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1138        let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1139        assert!(
1140            warnings_multiline.is_empty(),
1141            "Multi-line HTML comment is also transparent"
1142        );
1143    }
1144
1145    #[test]
1146    fn test_frontmatter_transparency() {
1147        // Frontmatter is transparent for MD022 - heading can appear immediately after
1148        // Verified with markdownlint: no MD022 warning for heading after frontmatter
1149        let rule = MD022BlanksAroundHeadings::default();
1150
1151        // Heading immediately after frontmatter closing ---
1152        let content = "---\ntitle: Test\n---\n# First heading";
1153        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1154        let warnings = rule.check(&ctx).unwrap();
1155        assert!(
1156            warnings.is_empty(),
1157            "Frontmatter is transparent - heading can appear immediately after"
1158        );
1159
1160        // Heading with blank line after frontmatter is also valid
1161        let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1162        let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1163        let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1164        assert!(
1165            warnings_with_blank.is_empty(),
1166            "Heading with blank line after frontmatter should also be valid"
1167        );
1168
1169        // TOML frontmatter (+++...+++) is also transparent
1170        let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1171        let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1172        let warnings_toml = rule.check(&ctx_toml).unwrap();
1173        assert!(
1174            warnings_toml.is_empty(),
1175            "TOML frontmatter is also transparent for MD022"
1176        );
1177    }
1178
1179    #[test]
1180    fn test_horizontal_rule_not_treated_as_frontmatter() {
1181        // Issue #238: Horizontal rules (---) should NOT be treated as frontmatter.
1182        // A heading after a horizontal rule MUST have a blank line above it.
1183        let rule = MD022BlanksAroundHeadings::default();
1184
1185        // Case 1: Heading immediately after horizontal rule - SHOULD warn
1186        let content = "Some content\n\n---\n# Heading after HR";
1187        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188        let warnings = rule.check(&ctx).unwrap();
1189        assert!(
1190            !warnings.is_empty(),
1191            "Heading after horizontal rule without blank line SHOULD trigger MD022"
1192        );
1193        assert!(
1194            warnings.iter().any(|w| w.line == 4),
1195            "Warning should be on line 4 (the heading line)"
1196        );
1197
1198        // Case 2: Heading with blank line after HR - should NOT warn
1199        let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1200        let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1201        let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1202        assert!(
1203            warnings_with_blank.is_empty(),
1204            "Heading with blank line after HR should not trigger MD022"
1205        );
1206
1207        // Case 3: HR at start of document followed by heading - SHOULD warn
1208        let content_hr_start = "---\n# Heading";
1209        let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1210        let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1211        assert!(
1212            !warnings_hr_start.is_empty(),
1213            "Heading after HR at document start SHOULD trigger MD022"
1214        );
1215
1216        // Case 4: Multiple HRs then heading - SHOULD warn
1217        let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1218        let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1219        let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1220        assert!(
1221            !warnings_multi_hr.is_empty(),
1222            "Heading after multiple HRs without blank line SHOULD trigger MD022"
1223        );
1224    }
1225
1226    #[test]
1227    fn test_all_hr_styles_require_blank_before_heading() {
1228        // CommonMark defines HRs as 3+ of -, *, or _ with optional spaces between
1229        let rule = MD022BlanksAroundHeadings::default();
1230
1231        // All valid HR styles that should trigger MD022 when followed by heading without blank
1232        let hr_styles = [
1233            "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1234            "-  -  -", // Multiple spaces between
1235            "  ---",   // 2 spaces indent (valid per CommonMark)
1236            "   ---",  // 3 spaces indent (valid per CommonMark)
1237        ];
1238
1239        for hr in hr_styles {
1240            let content = format!("Content\n\n{hr}\n# Heading");
1241            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1242            let warnings = rule.check(&ctx).unwrap();
1243            assert!(
1244                !warnings.is_empty(),
1245                "HR style '{hr}' followed by heading should trigger MD022"
1246            );
1247        }
1248    }
1249
1250    #[test]
1251    fn test_setext_heading_after_hr() {
1252        // Setext headings after HR should also require blank line
1253        let rule = MD022BlanksAroundHeadings::default();
1254
1255        // Setext h1 after HR without blank - SHOULD warn
1256        let content = "Content\n\n---\nHeading\n======";
1257        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258        let warnings = rule.check(&ctx).unwrap();
1259        assert!(
1260            !warnings.is_empty(),
1261            "Setext heading after HR without blank should trigger MD022"
1262        );
1263
1264        // Setext h2 after HR without blank - SHOULD warn
1265        let content_h2 = "Content\n\n---\nHeading\n------";
1266        let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1267        let warnings_h2 = rule.check(&ctx_h2).unwrap();
1268        assert!(
1269            !warnings_h2.is_empty(),
1270            "Setext h2 after HR without blank should trigger MD022"
1271        );
1272
1273        // With blank line - should NOT warn
1274        let content_ok = "Content\n\n---\n\nHeading\n======";
1275        let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1276        let warnings_ok = rule.check(&ctx_ok).unwrap();
1277        assert!(
1278            warnings_ok.is_empty(),
1279            "Setext heading with blank after HR should not warn"
1280        );
1281    }
1282
1283    #[test]
1284    fn test_hr_in_code_block_not_treated_as_hr() {
1285        // HR syntax inside code blocks should be ignored
1286        let rule = MD022BlanksAroundHeadings::default();
1287
1288        // HR inside fenced code block - heading after code block needs blank line check
1289        // but the "---" inside is NOT an HR
1290        let content = "```\n---\n```\n# Heading";
1291        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1292        let warnings = rule.check(&ctx).unwrap();
1293        // The heading is after a code block fence, not after an HR
1294        // This tests that we don't confuse code block content with HRs
1295        assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1296
1297        // With blank after code block - should be fine
1298        let content_ok = "```\n---\n```\n\n# Heading";
1299        let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1300        let warnings_ok = rule.check(&ctx_ok).unwrap();
1301        assert!(
1302            warnings_ok.is_empty(),
1303            "Heading with blank after code block should not warn"
1304        );
1305    }
1306
1307    #[test]
1308    fn test_hr_in_html_comment_not_treated_as_hr() {
1309        // HR syntax inside HTML comments should be ignored
1310        let rule = MD022BlanksAroundHeadings::default();
1311
1312        // "---" inside HTML comment is NOT an HR
1313        let content = "<!-- \n---\n -->\n# Heading";
1314        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1315        let warnings = rule.check(&ctx).unwrap();
1316        // HTML comments are transparent, so heading after comment at doc start is OK
1317        assert!(
1318            warnings.is_empty(),
1319            "HR inside HTML comment should be ignored - heading after comment is OK"
1320        );
1321    }
1322
1323    #[test]
1324    fn test_invalid_hr_not_triggering() {
1325        // These should NOT be recognized as HRs per CommonMark
1326        let rule = MD022BlanksAroundHeadings::default();
1327
1328        let invalid_hrs = [
1329            "    ---", // 4+ spaces is code block, not HR
1330            "\t---",   // Tab indent makes it code block
1331            "--",      // Only 2 dashes
1332            "**",      // Only 2 asterisks
1333            "__",      // Only 2 underscores
1334            "-*-",     // Mixed characters
1335            "---a",    // Extra character at end
1336            "a---",    // Extra character at start
1337        ];
1338
1339        for invalid in invalid_hrs {
1340            // These are NOT HRs, so if followed by heading, the heading behavior depends
1341            // on what the content actually is (code block, paragraph, etc.)
1342            let content = format!("Content\n\n{invalid}\n# Heading");
1343            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1344            // We're just verifying the HR detection is correct
1345            // The actual warning behavior depends on what the "invalid HR" is parsed as
1346            let _ = rule.check(&ctx);
1347        }
1348    }
1349
1350    #[test]
1351    fn test_frontmatter_vs_horizontal_rule_distinction() {
1352        // Ensure we correctly distinguish between frontmatter delimiters and standalone HRs
1353        let rule = MD022BlanksAroundHeadings::default();
1354
1355        // Frontmatter followed by content, then HR, then heading
1356        // The HR here is NOT frontmatter, so heading needs blank line
1357        let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1358        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1359        let warnings = rule.check(&ctx).unwrap();
1360        assert!(
1361            !warnings.is_empty(),
1362            "HR after frontmatter content should still require blank line before heading"
1363        );
1364
1365        // Same but with blank line after HR - should be fine
1366        let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1367        let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1368        let warnings_ok = rule.check(&ctx_ok).unwrap();
1369        assert!(
1370            warnings_ok.is_empty(),
1371            "HR with blank line before heading should not warn"
1372        );
1373    }
1374
1375    // ==================== Kramdown IAL Tests ====================
1376
1377    #[test]
1378    fn test_kramdown_ial_after_heading_no_warning() {
1379        // Issue #259: IAL immediately after heading should not trigger MD022
1380        let rule = MD022BlanksAroundHeadings::default();
1381        let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1382        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1383        let warnings = rule.check(&ctx).unwrap();
1384
1385        assert!(
1386            warnings.is_empty(),
1387            "IAL after heading should not require blank line between them: {warnings:?}"
1388        );
1389    }
1390
1391    #[test]
1392    fn test_kramdown_ial_with_class() {
1393        let rule = MD022BlanksAroundHeadings::default();
1394        let content = "# Heading\n{:.highlight}\n\nContent.";
1395        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1396        let warnings = rule.check(&ctx).unwrap();
1397
1398        assert!(warnings.is_empty(), "IAL with class should be part of heading");
1399    }
1400
1401    #[test]
1402    fn test_kramdown_ial_with_id() {
1403        let rule = MD022BlanksAroundHeadings::default();
1404        let content = "# Heading\n{:#custom-id}\n\nContent.";
1405        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1406        let warnings = rule.check(&ctx).unwrap();
1407
1408        assert!(warnings.is_empty(), "IAL with id should be part of heading");
1409    }
1410
1411    #[test]
1412    fn test_kramdown_ial_with_multiple_attributes() {
1413        let rule = MD022BlanksAroundHeadings::default();
1414        let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1415        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416        let warnings = rule.check(&ctx).unwrap();
1417
1418        assert!(
1419            warnings.is_empty(),
1420            "IAL with multiple attributes should be part of heading"
1421        );
1422    }
1423
1424    #[test]
1425    fn test_kramdown_ial_missing_blank_after() {
1426        // IAL is part of heading, but blank line is still needed after IAL
1427        let rule = MD022BlanksAroundHeadings::default();
1428        let content = "# Heading\n{:.class}\nContent without blank.";
1429        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1430        let warnings = rule.check(&ctx).unwrap();
1431
1432        assert_eq!(
1433            warnings.len(),
1434            1,
1435            "Should warn about missing blank after IAL (part of heading)"
1436        );
1437        assert!(warnings[0].message.contains("below"));
1438    }
1439
1440    #[test]
1441    fn test_kramdown_ial_before_heading_transparent() {
1442        // IAL before heading should be transparent for "blank lines above" check
1443        let rule = MD022BlanksAroundHeadings::default();
1444        let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1445        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1446        let warnings = rule.check(&ctx).unwrap();
1447
1448        assert!(
1449            warnings.is_empty(),
1450            "IAL before heading should be transparent for blank line count"
1451        );
1452    }
1453
1454    #[test]
1455    fn test_kramdown_ial_setext_heading() {
1456        let rule = MD022BlanksAroundHeadings::default();
1457        let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1458        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1459        let warnings = rule.check(&ctx).unwrap();
1460
1461        assert!(
1462            warnings.is_empty(),
1463            "IAL after Setext heading should be part of heading"
1464        );
1465    }
1466
1467    #[test]
1468    fn test_kramdown_ial_fix_preserves_ial() {
1469        let rule = MD022BlanksAroundHeadings::default();
1470        let content = "Content.\n# Heading\n{:.class}\nMore content.";
1471        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1472        let fixed = rule.fix(&ctx).unwrap();
1473
1474        // Should add blank line above heading and after IAL, but keep IAL attached to heading
1475        assert!(
1476            fixed.contains("# Heading\n{:.class}"),
1477            "IAL should stay attached to heading"
1478        );
1479        assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1480    }
1481
1482    #[test]
1483    fn test_kramdown_ial_fix_does_not_separate() {
1484        let rule = MD022BlanksAroundHeadings::default();
1485        let content = "# Heading\n{:.class}\nContent.";
1486        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1487        let fixed = rule.fix(&ctx).unwrap();
1488
1489        // Fix should NOT insert blank line between heading and IAL
1490        assert!(
1491            !fixed.contains("# Heading\n\n{:.class}"),
1492            "Should not add blank between heading and IAL"
1493        );
1494        assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1495    }
1496
1497    #[test]
1498    fn test_kramdown_multiple_ial_lines() {
1499        // Edge case: multiple IAL lines (unusual but valid)
1500        let rule = MD022BlanksAroundHeadings::default();
1501        let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1502        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1503        let warnings = rule.check(&ctx).unwrap();
1504
1505        // Note: Kramdown only attaches one IAL, but we treat consecutive ones as all attached
1506        // to avoid false positives
1507        assert!(
1508            warnings.is_empty(),
1509            "Multiple consecutive IALs should be part of heading"
1510        );
1511    }
1512
1513    #[test]
1514    fn test_kramdown_ial_with_blank_line_not_attached() {
1515        // If there's a blank line between heading and IAL, they're not attached
1516        let rule = MD022BlanksAroundHeadings::default();
1517        let content = "# Heading\n\n{:.class}\nContent.";
1518        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1519        let warnings = rule.check(&ctx).unwrap();
1520
1521        // The IAL here is NOT attached to the heading (blank line separates them)
1522        // So this should NOT trigger a warning for missing blank below heading
1523        // The IAL is just a standalone block-level element
1524        assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1525    }
1526
1527    #[test]
1528    fn test_not_kramdown_ial_regular_braces() {
1529        // Regular braces that don't match IAL pattern
1530        let rule = MD022BlanksAroundHeadings::default();
1531        let content = "# Heading\n{not an ial}\n\nContent.";
1532        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1533        let warnings = rule.check(&ctx).unwrap();
1534
1535        // {not an ial} is not IAL syntax, so it should be regular content
1536        assert_eq!(
1537            warnings.len(),
1538            1,
1539            "Non-IAL braces should be regular content requiring blank"
1540        );
1541    }
1542
1543    #[test]
1544    fn test_kramdown_ial_at_document_end() {
1545        let rule = MD022BlanksAroundHeadings::default();
1546        let content = "# Heading\n{:.class}";
1547        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1548        let warnings = rule.check(&ctx).unwrap();
1549
1550        // No content after IAL, so no blank line needed
1551        assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1552    }
1553
1554    #[test]
1555    fn test_kramdown_ial_followed_by_code_fence() {
1556        let rule = MD022BlanksAroundHeadings::default();
1557        let content = "# Heading\n{:.class}\n```\ncode\n```";
1558        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1559        let warnings = rule.check(&ctx).unwrap();
1560
1561        // Code fence is special - no blank required before it
1562        assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1563    }
1564
1565    #[test]
1566    fn test_kramdown_ial_followed_by_list() {
1567        let rule = MD022BlanksAroundHeadings::default();
1568        let content = "# Heading\n{:.class}\n- List item";
1569        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1570        let warnings = rule.check(&ctx).unwrap();
1571
1572        // List is special - no blank required before it
1573        assert!(warnings.is_empty(), "No blank needed between IAL and list");
1574    }
1575
1576    #[test]
1577    fn test_kramdown_ial_fix_idempotent() {
1578        let rule = MD022BlanksAroundHeadings::default();
1579        let content = "# Heading\n{:.class}\nContent.";
1580        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1581
1582        let fixed_once = rule.fix(&ctx).unwrap();
1583        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1584        let fixed_twice = rule.fix(&ctx2).unwrap();
1585
1586        assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1587    }
1588
1589    #[test]
1590    fn test_kramdown_ial_whitespace_line_between_not_attached() {
1591        // A whitespace-only line (not truly blank) between heading and IAL
1592        // means the IAL is NOT attached to the heading
1593        let rule = MD022BlanksAroundHeadings::default();
1594        let content = "# Heading\n   \n{:.class}\n\nContent.";
1595        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1596        let warnings = rule.check(&ctx).unwrap();
1597
1598        // Whitespace-only line is treated as blank, so IAL is NOT attached
1599        // The warning should be about the line after heading (whitespace line)
1600        // since {:.class} starts a new block
1601        assert!(
1602            warnings.is_empty(),
1603            "Whitespace between heading and IAL means IAL is not attached"
1604        );
1605    }
1606
1607    #[test]
1608    fn test_kramdown_ial_html_comment_between() {
1609        // HTML comment between heading and IAL means IAL is NOT attached to heading
1610        // IAL must immediately follow the element it modifies
1611        let rule = MD022BlanksAroundHeadings::default();
1612        let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1613        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1614        let warnings = rule.check(&ctx).unwrap();
1615
1616        // HTML comment creates separation - IAL is not attached to heading
1617        // Warning is generated because heading doesn't have blank line below
1618        // (the comment is transparent, but IAL is not attached)
1619        assert_eq!(
1620            warnings.len(),
1621            1,
1622            "IAL not attached when comment is between: {warnings:?}"
1623        );
1624    }
1625
1626    #[test]
1627    fn test_kramdown_ial_generic_attribute() {
1628        let rule = MD022BlanksAroundHeadings::default();
1629        let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1630        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1631        let warnings = rule.check(&ctx).unwrap();
1632
1633        assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1634    }
1635
1636    #[test]
1637    fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1638        let rule = MD022BlanksAroundHeadings::default();
1639        let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1640        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1641
1642        let fixed = rule.fix(&ctx).unwrap();
1643
1644        // All IAL lines should be preserved
1645        assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1646        assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1647        assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1648        // Blank line should be after all IALs, before content
1649        assert!(
1650            fixed.contains("{:data-x=\"y\"}\n\nContent"),
1651            "Blank line should be after all IALs"
1652        );
1653    }
1654
1655    #[test]
1656    fn test_kramdown_ial_crlf_line_endings() {
1657        let rule = MD022BlanksAroundHeadings::default();
1658        let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1659        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1660        let warnings = rule.check(&ctx).unwrap();
1661
1662        assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1663    }
1664
1665    #[test]
1666    fn test_kramdown_ial_invalid_patterns_not_recognized() {
1667        let rule = MD022BlanksAroundHeadings::default();
1668
1669        // Space before colon - not valid IAL
1670        let content = "# Heading\n{ :.class}\n\nContent.";
1671        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1672        let warnings = rule.check(&ctx).unwrap();
1673        assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1674
1675        // Missing colon entirely
1676        let content2 = "# Heading\n{.class}\n\nContent.";
1677        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1678        let warnings2 = rule.check(&ctx2).unwrap();
1679        // {.class} IS valid kramdown syntax (starts with .)
1680        assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1681
1682        // Just text in braces
1683        let content3 = "# Heading\n{just text}\n\nContent.";
1684        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1685        let warnings3 = rule.check(&ctx3).unwrap();
1686        assert_eq!(
1687            warnings3.len(),
1688            1,
1689            "Text in braces is not IAL and should trigger warning"
1690        );
1691    }
1692
1693    #[test]
1694    fn test_kramdown_ial_toc_marker() {
1695        // {:toc} is a special kramdown table of contents marker
1696        let rule = MD022BlanksAroundHeadings::default();
1697        let content = "# Heading\n{:toc}\n\nContent.";
1698        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1699        let warnings = rule.check(&ctx).unwrap();
1700
1701        // {:toc} starts with {: so it's recognized as IAL
1702        assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1703    }
1704
1705    #[test]
1706    fn test_kramdown_ial_mixed_headings_in_document() {
1707        let rule = MD022BlanksAroundHeadings::default();
1708        let content = r#"# ATX Heading
1709{:.atx-class}
1710
1711Content after ATX.
1712
1713Setext Heading
1714--------------
1715{:#setext-id}
1716
1717Content after Setext.
1718
1719## Another ATX
1720{:.another}
1721
1722More content."#;
1723        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1724        let warnings = rule.check(&ctx).unwrap();
1725
1726        assert!(
1727            warnings.is_empty(),
1728            "Mixed headings with IAL should all work: {warnings:?}"
1729        );
1730    }
1731
1732    // ==================== Quarto Flavor Tests ====================
1733
1734    #[test]
1735    fn test_quarto_div_marker_transparent_above_heading() {
1736        // Quarto div markers should be transparent for blank line counting
1737        // The blank line before the div opening should count toward the heading
1738        let rule = MD022BlanksAroundHeadings::default();
1739        // Content ends, blank, div opens, blank counts through div marker, heading
1740        let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1741        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1742        let warnings = rule.check(&ctx).unwrap();
1743        // The blank line before div opening should count as separation for heading
1744        assert!(
1745            warnings.is_empty(),
1746            "Quarto div marker should be transparent above heading: {warnings:?}"
1747        );
1748    }
1749
1750    #[test]
1751    fn test_quarto_div_marker_transparent_below_heading() {
1752        // Quarto div opening marker should be transparent for blank line counting below heading
1753        let rule = MD022BlanksAroundHeadings::default();
1754        let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
1755        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1756        let warnings = rule.check(&ctx).unwrap();
1757        // The blank line after heading should count, and ::: should be transparent
1758        assert!(
1759            warnings.is_empty(),
1760            "Quarto div marker should be transparent below heading: {warnings:?}"
1761        );
1762    }
1763
1764    #[test]
1765    fn test_quarto_heading_inside_callout() {
1766        // Heading inside Quarto callout should work normally
1767        let rule = MD022BlanksAroundHeadings::default();
1768        let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
1769        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1770        let warnings = rule.check(&ctx).unwrap();
1771        assert!(
1772            warnings.is_empty(),
1773            "Heading inside Quarto callout should have no warnings: {warnings:?}"
1774        );
1775    }
1776
1777    #[test]
1778    fn test_quarto_heading_at_start_after_div_open() {
1779        // Heading immediately after div open counts as being at document start
1780        // because div marker is transparent for "first heading" detection
1781        let rule = MD022BlanksAroundHeadings::default();
1782        // This is the first heading in the document (div marker is transparent)
1783        let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
1784        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1785        let warnings = rule.check(&ctx).unwrap();
1786        // The heading is at document start (after transparent div marker)
1787        // BUT the default config has allowed_at_start = true, AND there's content inside the div
1788        // that needs blank line below the heading. Let's check what we get.
1789        // Actually, the heading needs a blank below (before "Content"), so let's fix the test.
1790        // For this test, we want to verify the "above" requirement works with div marker transparency.
1791        assert!(
1792            warnings.is_empty(),
1793            "Heading at start after div open should pass: {warnings:?}"
1794        );
1795    }
1796
1797    #[test]
1798    fn test_quarto_heading_before_div_close() {
1799        // Heading immediately before div close: the div close is at end of doc, so no blank needed after
1800        let rule = MD022BlanksAroundHeadings::default();
1801        let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
1802        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1803        let warnings = rule.check(&ctx).unwrap();
1804        // The div closing marker is transparent, and at end of document there's nothing after it
1805        // So technically the heading is at the end (nothing follows the div close).
1806        // We need to check if the transparent marker logic works for end-of-document.
1807        assert!(
1808            warnings.is_empty(),
1809            "Heading before div close should pass: {warnings:?}"
1810        );
1811    }
1812
1813    #[test]
1814    fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
1815        // In standard flavor, ::: is regular text and breaks blank line sequences
1816        let rule = MD022BlanksAroundHeadings::default();
1817        let content = "Content\n\n:::\n# Heading\n\n:::\n";
1818        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1819        let warnings = rule.check(&ctx).unwrap();
1820        // In standard flavor, the ::: is just text. So there's no blank between ::: and heading.
1821        assert!(
1822            !warnings.is_empty(),
1823            "Standard flavor should not treat ::: as transparent: {warnings:?}"
1824        );
1825    }
1826
1827    #[test]
1828    fn test_quarto_nested_divs_with_heading() {
1829        // Nested Quarto divs with heading inside
1830        let rule = MD022BlanksAroundHeadings::default();
1831        let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
1832        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1833        let warnings = rule.check(&ctx).unwrap();
1834        assert!(
1835            warnings.is_empty(),
1836            "Nested divs with heading should work: {warnings:?}"
1837        );
1838    }
1839
1840    #[test]
1841    fn test_quarto_fix_preserves_div_markers() {
1842        // Fix should preserve Quarto div markers
1843        let rule = MD022BlanksAroundHeadings::default();
1844        let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
1845        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1846        let fixed = rule.fix(&ctx).unwrap();
1847        // Should preserve all the div markers
1848        assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
1849        assert!(fixed.contains(":::"), "Should preserve div closing");
1850        assert!(fixed.contains("## Note"), "Should preserve heading");
1851    }
1852
1853    #[test]
1854    fn test_quarto_heading_needs_blank_without_div_transparency() {
1855        // Without a blank line, heading after content should warn even with div marker between
1856        // This tests that blank lines are still required, div markers just don't "reset" the count
1857        let rule = MD022BlanksAroundHeadings::default();
1858        // Content directly followed by div opening, then heading - should warn
1859        let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
1860        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1861        let warnings = rule.check(&ctx).unwrap();
1862        // The div marker is transparent, so we look through it.
1863        // "Content" followed by heading with only a div marker in between - no blank!
1864        assert!(
1865            !warnings.is_empty(),
1866            "Should still require blank line when not present: {warnings:?}"
1867        );
1868    }
1869}