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