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 {
632                    range: byte_range,
633                    replacement: line_ending.repeat(needed_blanks),
634                }),
635            });
636        }
637
638        Ok(result)
639    }
640
641    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
642        if ctx.content.is_empty() {
643            return Ok(ctx.content.to_string());
644        }
645
646        // Use a consolidated fix that avoids adding multiple blank lines
647        let fixed = self._fix_content(ctx);
648
649        Ok(fixed)
650    }
651
652    /// Get the category of this rule for selective processing
653    fn category(&self) -> RuleCategory {
654        RuleCategory::Heading
655    }
656
657    /// Check if this rule should be skipped
658    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
659        // Fast path: check if document likely has headings
660        if ctx.content.is_empty() || !ctx.likely_has_headings() {
661            return true;
662        }
663        // Verify headings actually exist
664        ctx.lines.iter().all(|line| line.heading.is_none())
665    }
666
667    fn as_any(&self) -> &dyn std::any::Any {
668        self
669    }
670
671    fn default_config_section(&self) -> Option<(String, toml::Value)> {
672        let default_config = MD022Config::default();
673        let json_value = serde_json::to_value(&default_config).ok()?;
674        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
675
676        if let toml::Value::Table(table) = toml_value {
677            if !table.is_empty() {
678                Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
679            } else {
680                None
681            }
682        } else {
683            None
684        }
685    }
686
687    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
688    where
689        Self: Sized,
690    {
691        let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
692        Box::new(Self::from_config_struct(rule_config))
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699    use crate::lint_context::LintContext;
700
701    #[test]
702    fn test_valid_headings() {
703        let rule = MD022BlanksAroundHeadings::default();
704        let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
705        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
706        let result = rule.check(&ctx).unwrap();
707        assert!(result.is_empty());
708    }
709
710    #[test]
711    fn test_missing_blank_above() {
712        let rule = MD022BlanksAroundHeadings::default();
713        let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
714        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
715        let result = rule.check(&ctx).unwrap();
716        assert_eq!(result.len(), 0); // No warning for first heading
717
718        let fixed = rule.fix(&ctx).unwrap();
719
720        // Test for the ability to handle the content without breaking it
721        // Don't check for exact string equality which may break with implementation changes
722        assert!(fixed.contains("# Heading 1"));
723        assert!(fixed.contains("Some content."));
724        assert!(fixed.contains("## Heading 2"));
725        assert!(fixed.contains("More content."));
726    }
727
728    #[test]
729    fn test_missing_blank_below() {
730        let rule = MD022BlanksAroundHeadings::default();
731        let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
732        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733        let result = rule.check(&ctx).unwrap();
734        assert_eq!(result.len(), 1);
735        assert_eq!(result[0].line, 2);
736
737        // Test the fix
738        let fixed = rule.fix(&ctx).unwrap();
739        assert!(fixed.contains("# Heading 1\n\nSome content"));
740    }
741
742    #[test]
743    fn test_missing_blank_above_and_below() {
744        let rule = MD022BlanksAroundHeadings::default();
745        let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
746        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
747        let result = rule.check(&ctx).unwrap();
748        assert_eq!(result.len(), 3); // Missing blanks: below first heading, above second heading, below second heading
749
750        // Test the fix
751        let fixed = rule.fix(&ctx).unwrap();
752        assert!(fixed.contains("# Heading 1\n\nSome content"));
753        assert!(fixed.contains("Some content.\n\n## Heading 2"));
754        assert!(fixed.contains("## Heading 2\n\nMore content"));
755    }
756
757    #[test]
758    fn test_fix_headings() {
759        let rule = MD022BlanksAroundHeadings::default();
760        let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
761        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762        let result = rule.fix(&ctx).unwrap();
763
764        let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
765        assert_eq!(result, expected);
766    }
767
768    #[test]
769    fn test_consecutive_headings_pattern() {
770        let rule = MD022BlanksAroundHeadings::default();
771        let content = "# Heading 1\n## Heading 2\n### Heading 3";
772        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773        let result = rule.fix(&ctx).unwrap();
774
775        // Using more specific assertions to check the structure
776        let lines: Vec<&str> = result.lines().collect();
777        assert!(!lines.is_empty());
778
779        // Find the positions of the headings
780        let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
781        let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
782        let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
783
784        // Verify blank lines between headings
785        assert!(
786            h2_pos > h1_pos + 1,
787            "Should have at least one blank line after first heading"
788        );
789        assert!(
790            h3_pos > h2_pos + 1,
791            "Should have at least one blank line after second heading"
792        );
793
794        // Verify there's a blank line between h1 and h2
795        assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
796
797        // Verify there's a blank line between h2 and h3
798        assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
799    }
800
801    #[test]
802    fn test_blanks_around_setext_headings() {
803        let rule = MD022BlanksAroundHeadings::default();
804        let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
805        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806        let result = rule.fix(&ctx).unwrap();
807
808        // Check that the fix follows requirements without being too rigid about the exact output format
809        let lines: Vec<&str> = result.lines().collect();
810
811        // Verify key elements are present
812        assert!(result.contains("Heading 1"));
813        assert!(result.contains("========="));
814        assert!(result.contains("Some content."));
815        assert!(result.contains("Heading 2"));
816        assert!(result.contains("---------"));
817        assert!(result.contains("More content."));
818
819        // Verify structure ensures blank lines are added after headings
820        let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
821        let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
822        assert!(
823            some_content_idx > heading1_marker_idx + 1,
824            "Should have a blank line after the first heading"
825        );
826
827        let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
828        let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
829        assert!(
830            more_content_idx > heading2_marker_idx + 1,
831            "Should have a blank line after the second heading"
832        );
833
834        // Verify that the fixed content has no warnings
835        let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
836        let fixed_warnings = rule.check(&fixed_ctx).unwrap();
837        assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
838    }
839
840    #[test]
841    fn test_fix_specific_blank_line_cases() {
842        let rule = MD022BlanksAroundHeadings::default();
843
844        // Case 1: Testing consecutive headings
845        let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
846        let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
847        let result1 = rule.fix(&ctx1).unwrap();
848        // Verify structure rather than exact content as the fix implementation may vary
849        assert!(result1.contains("# Heading 1"));
850        assert!(result1.contains("## Heading 2"));
851        assert!(result1.contains("### Heading 3"));
852        // Ensure each heading has a blank line after it
853        let lines: Vec<&str> = result1.lines().collect();
854        let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
855        let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
856        assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
857        assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
858
859        // Case 2: Headings with content
860        let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
861        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
862        let result2 = rule.fix(&ctx2).unwrap();
863        // Verify structure
864        assert!(result2.contains("# Heading 1"));
865        assert!(result2.contains("Content under heading 1"));
866        assert!(result2.contains("## Heading 2"));
867        // Check spacing
868        let lines2: Vec<&str> = result2.lines().collect();
869        let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
870        let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
871        assert!(
872            lines2[h1_pos2 + 1].trim().is_empty(),
873            "Should have a blank line after heading 1"
874        );
875
876        // Case 3: Multiple consecutive headings with blank lines preserved
877        let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
878        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
879        let result3 = rule.fix(&ctx3).unwrap();
880        // Just verify it doesn't crash and properly formats headings
881        assert!(result3.contains("# Heading 1"));
882        assert!(result3.contains("## Heading 2"));
883        assert!(result3.contains("### Heading 3"));
884        assert!(result3.contains("Content"));
885    }
886
887    #[test]
888    fn test_fix_preserves_existing_blank_lines() {
889        let rule = MD022BlanksAroundHeadings::new();
890        let content = "# Title
891
892## Section 1
893
894Content here.
895
896## Section 2
897
898More content.
899### Missing Blank Above
900
901Even more content.
902
903## Section 3
904
905Final content.";
906
907        let expected = "# Title
908
909## Section 1
910
911Content here.
912
913## Section 2
914
915More content.
916
917### Missing Blank Above
918
919Even more content.
920
921## Section 3
922
923Final content.";
924
925        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926        let result = rule._fix_content(&ctx);
927        assert_eq!(
928            result, expected,
929            "Fix should only add missing blank lines, never remove existing ones"
930        );
931    }
932
933    #[test]
934    fn test_fix_preserves_trailing_newline() {
935        let rule = MD022BlanksAroundHeadings::new();
936
937        // Test with trailing newline
938        let content_with_newline = "# Title\nContent here.\n";
939        let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
940        let result = rule.fix(&ctx).unwrap();
941        assert!(result.ends_with('\n'), "Should preserve trailing newline");
942
943        // Test without trailing newline
944        let content_without_newline = "# Title\nContent here.";
945        let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
946        let result = rule.fix(&ctx).unwrap();
947        assert!(
948            !result.ends_with('\n'),
949            "Should not add trailing newline if original didn't have one"
950        );
951    }
952
953    #[test]
954    fn test_fix_does_not_add_blank_lines_before_lists() {
955        let rule = MD022BlanksAroundHeadings::new();
956        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.";
957
958        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.";
959
960        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
961        let result = rule._fix_content(&ctx);
962        assert_eq!(result, expected, "Fix should not add blank lines before lists");
963    }
964
965    #[test]
966    fn test_per_level_configuration_no_blank_above_h1() {
967        use md022_config::HeadingLevelConfig;
968
969        // Configure: no blank above H1, 1 blank above H2-H6
970        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
971            lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
972            lines_below: HeadingLevelConfig::scalar(1),
973            allowed_at_start: false, // Disable special handling for first heading
974        });
975
976        // H1 without blank above should be OK
977        let content = "Some text\n# Heading 1\n\nMore text";
978        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
979        let warnings = rule.check(&ctx).unwrap();
980        assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
981
982        // H2 without blank above should trigger warning
983        let content = "Some text\n## Heading 2\n\nMore text";
984        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
985        let warnings = rule.check(&ctx).unwrap();
986        assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
987        assert!(warnings[0].message.contains("above"));
988    }
989
990    #[test]
991    fn test_per_level_configuration_different_requirements() {
992        use md022_config::HeadingLevelConfig;
993
994        // Configure: 0 blank above H1, 1 above H2-H3, 2 above H4-H6
995        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
996            lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
997            lines_below: HeadingLevelConfig::scalar(1),
998            allowed_at_start: false,
999        });
1000
1001        let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
1002        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1003        let warnings = rule.check(&ctx).unwrap();
1004
1005        // Should have no warnings - all headings satisfy their level-specific requirements
1006        assert_eq!(
1007            warnings.len(),
1008            0,
1009            "All headings should satisfy level-specific requirements"
1010        );
1011    }
1012
1013    #[test]
1014    fn test_per_level_configuration_violations() {
1015        use md022_config::HeadingLevelConfig;
1016
1017        // Configure: H4 needs 2 blanks above
1018        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1019            lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
1020            lines_below: HeadingLevelConfig::scalar(1),
1021            allowed_at_start: false,
1022        });
1023
1024        // H4 with only 1 blank above should trigger warning
1025        let content = "Text\n\n#### Heading 4\n\nMore text";
1026        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1027        let warnings = rule.check(&ctx).unwrap();
1028
1029        assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1030        assert!(warnings[0].message.contains("2 blank lines above"));
1031    }
1032
1033    #[test]
1034    fn test_per_level_fix_different_levels() {
1035        use md022_config::HeadingLevelConfig;
1036
1037        // Configure: 0 blank above H1, 1 above H2, 2 above H3+
1038        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1039            lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1040            lines_below: HeadingLevelConfig::scalar(1),
1041            allowed_at_start: false,
1042        });
1043
1044        let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1045        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1046        let fixed = rule.fix(&ctx).unwrap();
1047
1048        // Verify structure: H1 gets 0 blanks above, H2 gets 1, H3 gets 2
1049        assert!(fixed.contains("Text\n# H1\n\nContent"));
1050        assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1051        assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1052    }
1053
1054    #[test]
1055    fn test_per_level_below_configuration() {
1056        use md022_config::HeadingLevelConfig;
1057
1058        // Configure: different blank line requirements below headings
1059        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1060            lines_above: HeadingLevelConfig::scalar(1),
1061            lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), // H1 needs 2 blanks below
1062            allowed_at_start: true,
1063        });
1064
1065        // H1 with only 1 blank below should trigger warning
1066        let content = "# Heading 1\n\nSome text";
1067        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1068        let warnings = rule.check(&ctx).unwrap();
1069
1070        assert_eq!(
1071            warnings.len(),
1072            1,
1073            "H1 with insufficient blanks below should trigger warning"
1074        );
1075        assert!(warnings[0].message.contains("2 blank lines below"));
1076    }
1077
1078    #[test]
1079    fn test_scalar_configuration_still_works() {
1080        use md022_config::HeadingLevelConfig;
1081
1082        // Ensure scalar configuration still works (backward compatibility)
1083        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1084            lines_above: HeadingLevelConfig::scalar(2),
1085            lines_below: HeadingLevelConfig::scalar(2),
1086            allowed_at_start: false,
1087        });
1088
1089        let content = "Text\n# H1\nContent\n## H2\nContent";
1090        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1091        let warnings = rule.check(&ctx).unwrap();
1092
1093        // All headings should need 2 blanks above and below
1094        assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1095    }
1096
1097    #[test]
1098    fn test_unlimited_configuration_skips_requirements() {
1099        use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1100
1101        // H1 can have any number of blank lines above/below; others require defaults
1102        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1103            lines_above: HeadingLevelConfig::per_level_requirements([
1104                HeadingBlankRequirement::unlimited(),
1105                HeadingBlankRequirement::limited(1),
1106                HeadingBlankRequirement::limited(1),
1107                HeadingBlankRequirement::limited(1),
1108                HeadingBlankRequirement::limited(1),
1109                HeadingBlankRequirement::limited(1),
1110            ]),
1111            lines_below: HeadingLevelConfig::per_level_requirements([
1112                HeadingBlankRequirement::unlimited(),
1113                HeadingBlankRequirement::limited(1),
1114                HeadingBlankRequirement::limited(1),
1115                HeadingBlankRequirement::limited(1),
1116                HeadingBlankRequirement::limited(1),
1117                HeadingBlankRequirement::limited(1),
1118            ]),
1119            allowed_at_start: false,
1120        });
1121
1122        let content = "# H1\nParagraph\n## H2\nParagraph";
1123        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1124        let warnings = rule.check(&ctx).unwrap();
1125
1126        // H1 has no blanks above/below but is unlimited; H2 should get violations
1127        assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1128        assert!(
1129            warnings.iter().all(|w| w.line >= 3),
1130            "Warnings should target later headings"
1131        );
1132
1133        // Fixing should insert blanks around H2 but leave H1 untouched
1134        let fixed = rule.fix(&ctx).unwrap();
1135        assert!(
1136            fixed.starts_with("# H1\nParagraph\n\n## H2"),
1137            "H1 should remain unchanged"
1138        );
1139    }
1140
1141    #[test]
1142    fn test_html_comment_transparency() {
1143        // HTML comments are transparent for blank line counting
1144        // A heading following a blank line + HTML comment should be valid
1145        // Verified with markdownlint: no MD022 warning for this pattern
1146        let rule = MD022BlanksAroundHeadings::default();
1147
1148        // Pattern: content, blank line, HTML comment, heading
1149        // The blank line before the HTML comment counts for the heading
1150        let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1151        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1152        let warnings = rule.check(&ctx).unwrap();
1153        assert!(
1154            warnings.is_empty(),
1155            "HTML comment is transparent - blank line above it counts for heading"
1156        );
1157
1158        // Multi-line HTML comment is also transparent
1159        let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1160        let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1161        let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1162        assert!(
1163            warnings_multiline.is_empty(),
1164            "Multi-line HTML comment is also transparent"
1165        );
1166    }
1167
1168    #[test]
1169    fn test_frontmatter_transparency() {
1170        // Frontmatter is transparent for MD022 - heading can appear immediately after
1171        // Verified with markdownlint: no MD022 warning for heading after frontmatter
1172        let rule = MD022BlanksAroundHeadings::default();
1173
1174        // Heading immediately after frontmatter closing ---
1175        let content = "---\ntitle: Test\n---\n# First heading";
1176        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1177        let warnings = rule.check(&ctx).unwrap();
1178        assert!(
1179            warnings.is_empty(),
1180            "Frontmatter is transparent - heading can appear immediately after"
1181        );
1182
1183        // Heading with blank line after frontmatter is also valid
1184        let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1185        let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1186        let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1187        assert!(
1188            warnings_with_blank.is_empty(),
1189            "Heading with blank line after frontmatter should also be valid"
1190        );
1191
1192        // TOML frontmatter (+++...+++) is also transparent
1193        let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1194        let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1195        let warnings_toml = rule.check(&ctx_toml).unwrap();
1196        assert!(
1197            warnings_toml.is_empty(),
1198            "TOML frontmatter is also transparent for MD022"
1199        );
1200    }
1201
1202    #[test]
1203    fn test_horizontal_rule_not_treated_as_frontmatter() {
1204        // Issue #238: Horizontal rules (---) should NOT be treated as frontmatter.
1205        // A heading after a horizontal rule MUST have a blank line above it.
1206        let rule = MD022BlanksAroundHeadings::default();
1207
1208        // Case 1: Heading immediately after horizontal rule - SHOULD warn
1209        let content = "Some content\n\n---\n# Heading after HR";
1210        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1211        let warnings = rule.check(&ctx).unwrap();
1212        assert!(
1213            !warnings.is_empty(),
1214            "Heading after horizontal rule without blank line SHOULD trigger MD022"
1215        );
1216        assert!(
1217            warnings.iter().any(|w| w.line == 4),
1218            "Warning should be on line 4 (the heading line)"
1219        );
1220
1221        // Case 2: Heading with blank line after HR - should NOT warn
1222        let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1223        let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1224        let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1225        assert!(
1226            warnings_with_blank.is_empty(),
1227            "Heading with blank line after HR should not trigger MD022"
1228        );
1229
1230        // Case 3: HR at start of document followed by heading - SHOULD warn
1231        let content_hr_start = "---\n# Heading";
1232        let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1233        let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1234        assert!(
1235            !warnings_hr_start.is_empty(),
1236            "Heading after HR at document start SHOULD trigger MD022"
1237        );
1238
1239        // Case 4: Multiple HRs then heading - SHOULD warn
1240        let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1241        let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1242        let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1243        assert!(
1244            !warnings_multi_hr.is_empty(),
1245            "Heading after multiple HRs without blank line SHOULD trigger MD022"
1246        );
1247    }
1248
1249    #[test]
1250    fn test_all_hr_styles_require_blank_before_heading() {
1251        // CommonMark defines HRs as 3+ of -, *, or _ with optional spaces between
1252        let rule = MD022BlanksAroundHeadings::default();
1253
1254        // All valid HR styles that should trigger MD022 when followed by heading without blank
1255        let hr_styles = [
1256            "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1257            "-  -  -", // Multiple spaces between
1258            "  ---",   // 2 spaces indent (valid per CommonMark)
1259            "   ---",  // 3 spaces indent (valid per CommonMark)
1260        ];
1261
1262        for hr in hr_styles {
1263            let content = format!("Content\n\n{hr}\n# Heading");
1264            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1265            let warnings = rule.check(&ctx).unwrap();
1266            assert!(
1267                !warnings.is_empty(),
1268                "HR style '{hr}' followed by heading should trigger MD022"
1269            );
1270        }
1271    }
1272
1273    #[test]
1274    fn test_setext_heading_after_hr() {
1275        // Setext headings after HR should also require blank line
1276        let rule = MD022BlanksAroundHeadings::default();
1277
1278        // Setext h1 after HR without blank - SHOULD warn
1279        let content = "Content\n\n---\nHeading\n======";
1280        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1281        let warnings = rule.check(&ctx).unwrap();
1282        assert!(
1283            !warnings.is_empty(),
1284            "Setext heading after HR without blank should trigger MD022"
1285        );
1286
1287        // Setext h2 after HR without blank - SHOULD warn
1288        let content_h2 = "Content\n\n---\nHeading\n------";
1289        let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1290        let warnings_h2 = rule.check(&ctx_h2).unwrap();
1291        assert!(
1292            !warnings_h2.is_empty(),
1293            "Setext h2 after HR without blank should trigger MD022"
1294        );
1295
1296        // With blank line - should NOT warn
1297        let content_ok = "Content\n\n---\n\nHeading\n======";
1298        let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1299        let warnings_ok = rule.check(&ctx_ok).unwrap();
1300        assert!(
1301            warnings_ok.is_empty(),
1302            "Setext heading with blank after HR should not warn"
1303        );
1304    }
1305
1306    #[test]
1307    fn test_hr_in_code_block_not_treated_as_hr() {
1308        // HR syntax inside code blocks should be ignored
1309        let rule = MD022BlanksAroundHeadings::default();
1310
1311        // HR inside fenced code block - heading after code block needs blank line check
1312        // but the "---" inside is NOT an HR
1313        let content = "```\n---\n```\n# Heading";
1314        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1315        let warnings = rule.check(&ctx).unwrap();
1316        // The heading is after a code block fence, not after an HR
1317        // This tests that we don't confuse code block content with HRs
1318        assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1319
1320        // With blank after code block - should be fine
1321        let content_ok = "```\n---\n```\n\n# Heading";
1322        let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1323        let warnings_ok = rule.check(&ctx_ok).unwrap();
1324        assert!(
1325            warnings_ok.is_empty(),
1326            "Heading with blank after code block should not warn"
1327        );
1328    }
1329
1330    #[test]
1331    fn test_hr_in_html_comment_not_treated_as_hr() {
1332        // HR syntax inside HTML comments should be ignored
1333        let rule = MD022BlanksAroundHeadings::default();
1334
1335        // "---" inside HTML comment is NOT an HR
1336        let content = "<!-- \n---\n -->\n# Heading";
1337        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1338        let warnings = rule.check(&ctx).unwrap();
1339        // HTML comments are transparent, so heading after comment at doc start is OK
1340        assert!(
1341            warnings.is_empty(),
1342            "HR inside HTML comment should be ignored - heading after comment is OK"
1343        );
1344    }
1345
1346    #[test]
1347    fn test_invalid_hr_not_triggering() {
1348        // These should NOT be recognized as HRs per CommonMark
1349        let rule = MD022BlanksAroundHeadings::default();
1350
1351        let invalid_hrs = [
1352            "    ---", // 4+ spaces is code block, not HR
1353            "\t---",   // Tab indent makes it code block
1354            "--",      // Only 2 dashes
1355            "**",      // Only 2 asterisks
1356            "__",      // Only 2 underscores
1357            "-*-",     // Mixed characters
1358            "---a",    // Extra character at end
1359            "a---",    // Extra character at start
1360        ];
1361
1362        for invalid in invalid_hrs {
1363            // These are NOT HRs, so if followed by heading, the heading behavior depends
1364            // on what the content actually is (code block, paragraph, etc.)
1365            let content = format!("Content\n\n{invalid}\n# Heading");
1366            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1367            // We're just verifying the HR detection is correct
1368            // The actual warning behavior depends on what the "invalid HR" is parsed as
1369            let _ = rule.check(&ctx);
1370        }
1371    }
1372
1373    #[test]
1374    fn test_frontmatter_vs_horizontal_rule_distinction() {
1375        // Ensure we correctly distinguish between frontmatter delimiters and standalone HRs
1376        let rule = MD022BlanksAroundHeadings::default();
1377
1378        // Frontmatter followed by content, then HR, then heading
1379        // The HR here is NOT frontmatter, so heading needs blank line
1380        let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1381        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1382        let warnings = rule.check(&ctx).unwrap();
1383        assert!(
1384            !warnings.is_empty(),
1385            "HR after frontmatter content should still require blank line before heading"
1386        );
1387
1388        // Same but with blank line after HR - should be fine
1389        let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1390        let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1391        let warnings_ok = rule.check(&ctx_ok).unwrap();
1392        assert!(
1393            warnings_ok.is_empty(),
1394            "HR with blank line before heading should not warn"
1395        );
1396    }
1397
1398    // ==================== Kramdown IAL Tests ====================
1399
1400    #[test]
1401    fn test_kramdown_ial_after_heading_no_warning() {
1402        // Issue #259: IAL immediately after heading should not trigger MD022
1403        let rule = MD022BlanksAroundHeadings::default();
1404        let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1405        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1406        let warnings = rule.check(&ctx).unwrap();
1407
1408        assert!(
1409            warnings.is_empty(),
1410            "IAL after heading should not require blank line between them: {warnings:?}"
1411        );
1412    }
1413
1414    #[test]
1415    fn test_kramdown_ial_with_class() {
1416        let rule = MD022BlanksAroundHeadings::default();
1417        let content = "# Heading\n{:.highlight}\n\nContent.";
1418        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1419        let warnings = rule.check(&ctx).unwrap();
1420
1421        assert!(warnings.is_empty(), "IAL with class should be part of heading");
1422    }
1423
1424    #[test]
1425    fn test_kramdown_ial_with_id() {
1426        let rule = MD022BlanksAroundHeadings::default();
1427        let content = "# Heading\n{:#custom-id}\n\nContent.";
1428        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1429        let warnings = rule.check(&ctx).unwrap();
1430
1431        assert!(warnings.is_empty(), "IAL with id should be part of heading");
1432    }
1433
1434    #[test]
1435    fn test_kramdown_ial_with_multiple_attributes() {
1436        let rule = MD022BlanksAroundHeadings::default();
1437        let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1438        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1439        let warnings = rule.check(&ctx).unwrap();
1440
1441        assert!(
1442            warnings.is_empty(),
1443            "IAL with multiple attributes should be part of heading"
1444        );
1445    }
1446
1447    #[test]
1448    fn test_kramdown_ial_missing_blank_after() {
1449        // IAL is part of heading, but blank line is still needed after IAL
1450        let rule = MD022BlanksAroundHeadings::default();
1451        let content = "# Heading\n{:.class}\nContent without blank.";
1452        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1453        let warnings = rule.check(&ctx).unwrap();
1454
1455        assert_eq!(
1456            warnings.len(),
1457            1,
1458            "Should warn about missing blank after IAL (part of heading)"
1459        );
1460        assert!(warnings[0].message.contains("below"));
1461    }
1462
1463    #[test]
1464    fn test_kramdown_ial_before_heading_transparent() {
1465        // IAL before heading should be transparent for "blank lines above" check
1466        let rule = MD022BlanksAroundHeadings::default();
1467        let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1468        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1469        let warnings = rule.check(&ctx).unwrap();
1470
1471        assert!(
1472            warnings.is_empty(),
1473            "IAL before heading should be transparent for blank line count"
1474        );
1475    }
1476
1477    #[test]
1478    fn test_kramdown_ial_setext_heading() {
1479        let rule = MD022BlanksAroundHeadings::default();
1480        let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1481        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1482        let warnings = rule.check(&ctx).unwrap();
1483
1484        assert!(
1485            warnings.is_empty(),
1486            "IAL after Setext heading should be part of heading"
1487        );
1488    }
1489
1490    #[test]
1491    fn test_kramdown_ial_fix_preserves_ial() {
1492        let rule = MD022BlanksAroundHeadings::default();
1493        let content = "Content.\n# Heading\n{:.class}\nMore content.";
1494        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1495        let fixed = rule.fix(&ctx).unwrap();
1496
1497        // Should add blank line above heading and after IAL, but keep IAL attached to heading
1498        assert!(
1499            fixed.contains("# Heading\n{:.class}"),
1500            "IAL should stay attached to heading"
1501        );
1502        assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1503    }
1504
1505    #[test]
1506    fn test_kramdown_ial_fix_does_not_separate() {
1507        let rule = MD022BlanksAroundHeadings::default();
1508        let content = "# Heading\n{:.class}\nContent.";
1509        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1510        let fixed = rule.fix(&ctx).unwrap();
1511
1512        // Fix should NOT insert blank line between heading and IAL
1513        assert!(
1514            !fixed.contains("# Heading\n\n{:.class}"),
1515            "Should not add blank between heading and IAL"
1516        );
1517        assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1518    }
1519
1520    #[test]
1521    fn test_kramdown_multiple_ial_lines() {
1522        // Edge case: multiple IAL lines (unusual but valid)
1523        let rule = MD022BlanksAroundHeadings::default();
1524        let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1525        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1526        let warnings = rule.check(&ctx).unwrap();
1527
1528        // Note: Kramdown only attaches one IAL, but we treat consecutive ones as all attached
1529        // to avoid false positives
1530        assert!(
1531            warnings.is_empty(),
1532            "Multiple consecutive IALs should be part of heading"
1533        );
1534    }
1535
1536    #[test]
1537    fn test_kramdown_ial_with_blank_line_not_attached() {
1538        // If there's a blank line between heading and IAL, they're not attached
1539        let rule = MD022BlanksAroundHeadings::default();
1540        let content = "# Heading\n\n{:.class}\nContent.";
1541        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1542        let warnings = rule.check(&ctx).unwrap();
1543
1544        // The IAL here is NOT attached to the heading (blank line separates them)
1545        // So this should NOT trigger a warning for missing blank below heading
1546        // The IAL is just a standalone block-level element
1547        assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1548    }
1549
1550    #[test]
1551    fn test_not_kramdown_ial_regular_braces() {
1552        // Regular braces that don't match IAL pattern
1553        let rule = MD022BlanksAroundHeadings::default();
1554        let content = "# Heading\n{not an ial}\n\nContent.";
1555        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1556        let warnings = rule.check(&ctx).unwrap();
1557
1558        // {not an ial} is not IAL syntax, so it should be regular content
1559        assert_eq!(
1560            warnings.len(),
1561            1,
1562            "Non-IAL braces should be regular content requiring blank"
1563        );
1564    }
1565
1566    #[test]
1567    fn test_kramdown_ial_at_document_end() {
1568        let rule = MD022BlanksAroundHeadings::default();
1569        let content = "# Heading\n{:.class}";
1570        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1571        let warnings = rule.check(&ctx).unwrap();
1572
1573        // No content after IAL, so no blank line needed
1574        assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1575    }
1576
1577    #[test]
1578    fn test_kramdown_ial_followed_by_code_fence() {
1579        let rule = MD022BlanksAroundHeadings::default();
1580        let content = "# Heading\n{:.class}\n```\ncode\n```";
1581        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1582        let warnings = rule.check(&ctx).unwrap();
1583
1584        // Code fence is special - no blank required before it
1585        assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1586    }
1587
1588    #[test]
1589    fn test_kramdown_ial_followed_by_list() {
1590        let rule = MD022BlanksAroundHeadings::default();
1591        let content = "# Heading\n{:.class}\n- List item";
1592        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1593        let warnings = rule.check(&ctx).unwrap();
1594
1595        // List is special - no blank required before it
1596        assert!(warnings.is_empty(), "No blank needed between IAL and list");
1597    }
1598
1599    #[test]
1600    fn test_kramdown_ial_fix_idempotent() {
1601        let rule = MD022BlanksAroundHeadings::default();
1602        let content = "# Heading\n{:.class}\nContent.";
1603        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1604
1605        let fixed_once = rule.fix(&ctx).unwrap();
1606        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1607        let fixed_twice = rule.fix(&ctx2).unwrap();
1608
1609        assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1610    }
1611
1612    #[test]
1613    fn test_kramdown_ial_whitespace_line_between_not_attached() {
1614        // A whitespace-only line (not truly blank) between heading and IAL
1615        // means the IAL is NOT attached to the heading
1616        let rule = MD022BlanksAroundHeadings::default();
1617        let content = "# Heading\n   \n{:.class}\n\nContent.";
1618        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1619        let warnings = rule.check(&ctx).unwrap();
1620
1621        // Whitespace-only line is treated as blank, so IAL is NOT attached
1622        // The warning should be about the line after heading (whitespace line)
1623        // since {:.class} starts a new block
1624        assert!(
1625            warnings.is_empty(),
1626            "Whitespace between heading and IAL means IAL is not attached"
1627        );
1628    }
1629
1630    #[test]
1631    fn test_kramdown_ial_html_comment_between() {
1632        // HTML comment between heading and IAL means IAL is NOT attached to heading
1633        // IAL must immediately follow the element it modifies
1634        let rule = MD022BlanksAroundHeadings::default();
1635        let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1636        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1637        let warnings = rule.check(&ctx).unwrap();
1638
1639        // HTML comment creates separation - IAL is not attached to heading
1640        // Warning is generated because heading doesn't have blank line below
1641        // (the comment is transparent, but IAL is not attached)
1642        assert_eq!(
1643            warnings.len(),
1644            1,
1645            "IAL not attached when comment is between: {warnings:?}"
1646        );
1647    }
1648
1649    #[test]
1650    fn test_kramdown_ial_generic_attribute() {
1651        let rule = MD022BlanksAroundHeadings::default();
1652        let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1653        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1654        let warnings = rule.check(&ctx).unwrap();
1655
1656        assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1657    }
1658
1659    #[test]
1660    fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1661        let rule = MD022BlanksAroundHeadings::default();
1662        let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1663        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1664
1665        let fixed = rule.fix(&ctx).unwrap();
1666
1667        // All IAL lines should be preserved
1668        assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1669        assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1670        assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1671        // Blank line should be after all IALs, before content
1672        assert!(
1673            fixed.contains("{:data-x=\"y\"}\n\nContent"),
1674            "Blank line should be after all IALs"
1675        );
1676    }
1677
1678    #[test]
1679    fn test_kramdown_ial_crlf_line_endings() {
1680        let rule = MD022BlanksAroundHeadings::default();
1681        let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1682        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1683        let warnings = rule.check(&ctx).unwrap();
1684
1685        assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1686    }
1687
1688    #[test]
1689    fn test_kramdown_ial_invalid_patterns_not_recognized() {
1690        let rule = MD022BlanksAroundHeadings::default();
1691
1692        // Space before colon - not valid IAL
1693        let content = "# Heading\n{ :.class}\n\nContent.";
1694        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1695        let warnings = rule.check(&ctx).unwrap();
1696        assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1697
1698        // Missing colon entirely
1699        let content2 = "# Heading\n{.class}\n\nContent.";
1700        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1701        let warnings2 = rule.check(&ctx2).unwrap();
1702        // {.class} IS valid kramdown syntax (starts with .)
1703        assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1704
1705        // Just text in braces
1706        let content3 = "# Heading\n{just text}\n\nContent.";
1707        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1708        let warnings3 = rule.check(&ctx3).unwrap();
1709        assert_eq!(
1710            warnings3.len(),
1711            1,
1712            "Text in braces is not IAL and should trigger warning"
1713        );
1714    }
1715
1716    #[test]
1717    fn test_kramdown_ial_toc_marker() {
1718        // {:toc} is a special kramdown table of contents marker
1719        let rule = MD022BlanksAroundHeadings::default();
1720        let content = "# Heading\n{:toc}\n\nContent.";
1721        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1722        let warnings = rule.check(&ctx).unwrap();
1723
1724        // {:toc} starts with {: so it's recognized as IAL
1725        assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1726    }
1727
1728    #[test]
1729    fn test_kramdown_ial_mixed_headings_in_document() {
1730        let rule = MD022BlanksAroundHeadings::default();
1731        let content = r#"# ATX Heading
1732{:.atx-class}
1733
1734Content after ATX.
1735
1736Setext Heading
1737--------------
1738{:#setext-id}
1739
1740Content after Setext.
1741
1742## Another ATX
1743{:.another}
1744
1745More content."#;
1746        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1747        let warnings = rule.check(&ctx).unwrap();
1748
1749        assert!(
1750            warnings.is_empty(),
1751            "Mixed headings with IAL should all work: {warnings:?}"
1752        );
1753    }
1754
1755    #[test]
1756    fn test_kramdown_extension_block_before_first_heading_is_document_start() {
1757        let rule = MD022BlanksAroundHeadings::default();
1758        let content = "{::comment}\nhidden\n{:/comment}\n# Heading\n\nBody\n";
1759        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1760        let warnings = rule.check(&ctx).unwrap();
1761
1762        assert!(
1763            warnings.is_empty(),
1764            "Kramdown extension preamble should not require blank above first heading: {warnings:?}"
1765        );
1766    }
1767
1768    #[test]
1769    fn test_kramdown_ial_before_first_heading_is_document_start() {
1770        let rule = MD022BlanksAroundHeadings::default();
1771        let content = "{:.doc-class}\n# Heading\n\nBody\n";
1772        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1773        let warnings = rule.check(&ctx).unwrap();
1774
1775        assert!(
1776            warnings.is_empty(),
1777            "Kramdown IAL preamble should not require blank above first heading: {warnings:?}"
1778        );
1779    }
1780
1781    // ==================== Quarto Flavor Tests ====================
1782
1783    #[test]
1784    fn test_quarto_div_marker_transparent_above_heading() {
1785        // Quarto div markers should be transparent for blank line counting
1786        // The blank line before the div opening should count toward the heading
1787        let rule = MD022BlanksAroundHeadings::default();
1788        // Content ends, blank, div opens, blank counts through div marker, heading
1789        let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1790        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1791        let warnings = rule.check(&ctx).unwrap();
1792        // The blank line before div opening should count as separation for heading
1793        assert!(
1794            warnings.is_empty(),
1795            "Quarto div marker should be transparent above heading: {warnings:?}"
1796        );
1797    }
1798
1799    #[test]
1800    fn test_quarto_div_marker_transparent_below_heading() {
1801        // Quarto div opening marker should be transparent for blank line counting below heading
1802        let rule = MD022BlanksAroundHeadings::default();
1803        let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
1804        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1805        let warnings = rule.check(&ctx).unwrap();
1806        // The blank line after heading should count, and ::: should be transparent
1807        assert!(
1808            warnings.is_empty(),
1809            "Quarto div marker should be transparent below heading: {warnings:?}"
1810        );
1811    }
1812
1813    #[test]
1814    fn test_quarto_heading_inside_callout() {
1815        // Heading inside Quarto callout should work normally
1816        let rule = MD022BlanksAroundHeadings::default();
1817        let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
1818        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1819        let warnings = rule.check(&ctx).unwrap();
1820        assert!(
1821            warnings.is_empty(),
1822            "Heading inside Quarto callout should have no warnings: {warnings:?}"
1823        );
1824    }
1825
1826    #[test]
1827    fn test_quarto_heading_at_start_after_div_open() {
1828        // Heading immediately after div open counts as being at document start
1829        // because div marker is transparent for "first heading" detection
1830        let rule = MD022BlanksAroundHeadings::default();
1831        // This is the first heading in the document (div marker is transparent)
1832        let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
1833        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1834        let warnings = rule.check(&ctx).unwrap();
1835        // The heading is at document start (after transparent div marker)
1836        // BUT the default config has allowed_at_start = true, AND there's content inside the div
1837        // that needs blank line below the heading. Let's check what we get.
1838        // Actually, the heading needs a blank below (before "Content"), so let's fix the test.
1839        // For this test, we want to verify the "above" requirement works with div marker transparency.
1840        assert!(
1841            warnings.is_empty(),
1842            "Heading at start after div open should pass: {warnings:?}"
1843        );
1844    }
1845
1846    #[test]
1847    fn test_quarto_heading_before_div_close() {
1848        // Heading immediately before div close: the div close is at end of doc, so no blank needed after
1849        let rule = MD022BlanksAroundHeadings::default();
1850        let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
1851        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1852        let warnings = rule.check(&ctx).unwrap();
1853        // The div closing marker is transparent, and at end of document there's nothing after it
1854        // So technically the heading is at the end (nothing follows the div close).
1855        // We need to check if the transparent marker logic works for end-of-document.
1856        assert!(
1857            warnings.is_empty(),
1858            "Heading before div close should pass: {warnings:?}"
1859        );
1860    }
1861
1862    #[test]
1863    fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
1864        // In standard flavor, ::: is regular text and breaks blank line sequences
1865        let rule = MD022BlanksAroundHeadings::default();
1866        let content = "Content\n\n:::\n# Heading\n\n:::\n";
1867        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1868        let warnings = rule.check(&ctx).unwrap();
1869        // In standard flavor, the ::: is just text. So there's no blank between ::: and heading.
1870        assert!(
1871            !warnings.is_empty(),
1872            "Standard flavor should not treat ::: as transparent: {warnings:?}"
1873        );
1874    }
1875
1876    #[test]
1877    fn test_quarto_nested_divs_with_heading() {
1878        // Nested Quarto divs with heading inside
1879        let rule = MD022BlanksAroundHeadings::default();
1880        let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
1881        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1882        let warnings = rule.check(&ctx).unwrap();
1883        assert!(
1884            warnings.is_empty(),
1885            "Nested divs with heading should work: {warnings:?}"
1886        );
1887    }
1888
1889    #[test]
1890    fn test_quarto_fix_preserves_div_markers() {
1891        // Fix should preserve Quarto div markers
1892        let rule = MD022BlanksAroundHeadings::default();
1893        let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
1894        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1895        let fixed = rule.fix(&ctx).unwrap();
1896        // Should preserve all the div markers
1897        assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
1898        assert!(fixed.contains(":::"), "Should preserve div closing");
1899        assert!(fixed.contains("## Note"), "Should preserve heading");
1900    }
1901
1902    #[test]
1903    fn test_quarto_heading_needs_blank_without_div_transparency() {
1904        // Without a blank line, heading after content should warn even with div marker between
1905        // This tests that blank lines are still required, div markers just don't "reset" the count
1906        let rule = MD022BlanksAroundHeadings::default();
1907        // Content directly followed by div opening, then heading - should warn
1908        let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
1909        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1910        let warnings = rule.check(&ctx).unwrap();
1911        // The div marker is transparent, so we look through it.
1912        // "Content" followed by heading with only a div marker in between - no blank!
1913        assert!(
1914            !warnings.is_empty(),
1915            "Should still require blank line when not present: {warnings:?}"
1916        );
1917    }
1918}