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