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