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