rumdl_lib/rules/
md022_blanks_around_headings.rs

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